Track data object versions locally

This commit is contained in:
Abe Jellinek 2026-05-12 10:20:26 -04:00
parent 431e1dabde
commit d270fd8a9f
17 changed files with 176 additions and 12 deletions

View file

@ -67,6 +67,10 @@ Zotero.defineProperty(Zotero.Collection.prototype, 'version', {
get: function () { return this._get('version'); },
set: function (val) { return this._set('version', val); }
});
Zotero.defineProperty(Zotero.Collection.prototype, 'clientVersion', {
get: function() { return this._get('clientVersion'); },
set: function(val) { return this._set('clientVersion', val); }
});
Zotero.defineProperty(Zotero.Collection.prototype, 'synced', {
get: function () { return this._get('synced'); },
set: function (val) { return this._set('synced', val); }

View file

@ -38,6 +38,7 @@ Zotero.Collections = function () {
libraryID: "O.libraryID",
key: "O.key",
version: "O.version",
clientVersion: "O.clientVersion",
synced: "O.synced",
deleted: "DC.collectionID IS NOT NULL AS deleted",

View file

@ -23,6 +23,13 @@
***** END LICENSE BLOCK *****
*/
let lazy = {};
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
'shouldTrackClientVersions',
'extensions.zotero.httpServer.localAPI.enabled',
);
/**
* @property {String} (readOnly) objectType
* @property {String} (readOnly) libraryKey
@ -50,6 +57,7 @@ Zotero.DataObject = function () {
this._dateAdded = null;
this._dateModified = null;
this._version = null;
this._clientVersion = null;
this._synced = null;
this._identified = false;
this._parentID = null;
@ -1179,6 +1187,15 @@ Zotero.DataObject.prototype._finalizeSave = async function (env) {
else if (env.skipCache) {
Zotero.logError("skipCache is only for new objects");
}
if (lazy.shouldTrackClientVersions) {
let libraryClientVersion = await this.library.incrementClientVersion();
await Zotero.DB.queryAsync(
`UPDATE ${this.ObjectsClass.table} SET clientVersion = ? WHERE ${this.ObjectsClass.idColumn}=?`,
[libraryClientVersion, this.id]
);
this._clientVersion = libraryClientVersion;
}
};
@ -1368,7 +1385,7 @@ Zotero.DataObject.prototype.toResponseJSON = function (options = {}) {
let uri = Zotero.URI.getObjectURI(this);
var json = {
key: this.key,
version: this.version,
version: this.clientVersion,
library: this.library.toResponseJSON({ ...options, includeGroupDetails: false }),
links: {
self: {

View file

@ -244,7 +244,7 @@ Zotero.Group.prototype.toResponseJSON = function (options = {}) {
let uri = Zotero.URI.getGroupURI(this);
return {
id: this.id,
version: this.version,
version: this.clientVersion,
links: {
self: {
href: Zotero.URI.toAPIURL(uri, options.apiURL),

View file

@ -128,8 +128,8 @@ Zotero.defineProperty(Zotero.Item.prototype, 'itemID', {
enumerable: false
});
for (let name of ['libraryID', 'key', 'dateAdded', 'dateModified', 'version', 'synced',
'createdByUserID', 'lastModifiedByUserID']) {
for (let name of ['libraryID', 'key', 'dateAdded', 'dateModified', 'version', 'clientVersion',
'synced', 'createdByUserID', 'lastModifiedByUserID']) {
let prop = '_' + name;
Zotero.defineProperty(Zotero.Item.prototype, name, {
get: function () { return this[prop]; },

View file

@ -48,6 +48,7 @@ Zotero.Items = function () {
libraryID: "O.libraryID",
key: "O.key",
version: "O.version",
clientVersion: "O.clientVersion",
synced: "O.synced",
createdByUserID: "createdByUserID",

View file

@ -39,6 +39,7 @@ Zotero.Library = function (params = {}) {
this._storageDownloadNeeded = false;
this._lastReadItemInSession = null;
this._lastClientVersionIncrementTransactionID = null;
Zotero.Utilities.Internal.assignProps(
this,
@ -48,6 +49,7 @@ Zotero.Library = function (params = {}) {
'editable',
'filesEditable',
'libraryVersion',
'clientVersion',
'storageVersion',
'lastSync',
'archived'
@ -71,7 +73,7 @@ Zotero.Library = function (params = {}) {
// DB columns
Zotero.defineProperty(Zotero.Library, '_dbColumns', {
value: Object.freeze([
'type', 'editable', 'filesEditable', 'version', 'storageVersion', 'lastSync', 'archived', 'isAdmin'
'type', 'editable', 'filesEditable', 'version', 'clientVersion', 'storageVersion', 'lastSync', 'archived', 'isAdmin'
])
});
@ -209,7 +211,7 @@ Zotero.defineProperty(Zotero.Library.prototype, 'allowsLinkedFiles', {
// Create other accessors
(function () {
let accessors = ['editable', 'filesEditable', 'storageVersion', 'archived', 'isAdmin'];
let accessors = ['editable', 'filesEditable', 'clientVersion', 'storageVersion', 'archived', 'isAdmin'];
for (let i=0; i<accessors.length; i++) {
let prop = Zotero.Library._colToProp(accessors[i]);
Zotero.defineProperty(Zotero.Library.prototype, accessors[i], {
@ -293,6 +295,20 @@ Zotero.Library.prototype._set = function (prop, val) {
break;
case '_libraryClientVersion':
var newVal = Number.parseInt(val, 10);
if (newVal != val) {
throw new Error(`${prop} must be an integer (${typeof val} '${val}' given)`);
}
val = newVal;
if (val < 0) throw new Error(prop + ' must not be less than 0');
// Ensure that it is never decreasing
if (val < this._libraryClientVersion) throw new Error(prop + ' cannot decrease');
break;
case '_libraryStorageVersion':
var newVal = parseInt(val);
if (newVal != val) {
@ -350,6 +366,7 @@ Zotero.Library.prototype._loadDataFromRow = function (row) {
this._libraryEditable = !!row._libraryEditable;
this._libraryFilesEditable = !!row._libraryFilesEditable;
this._libraryVersion = row._libraryVersion;
this._libraryClientVersion = row._libraryClientVersion;
this._libraryStorageVersion = row._libraryStorageVersion;
this._libraryLastSync = row._libraryLastSync !== 0 ? new Date(row._libraryLastSync * 1000) : false;
this._libraryArchived = !!row._libraryArchived;
@ -751,3 +768,18 @@ Zotero.Library.prototype.hasItem = function (item) {
}
return item.libraryID == this.libraryID;
}
Zotero.Library.prototype.incrementClientVersion = async function () {
let transactionID = Zotero.DB.requireTransaction();
if (transactionID === this._lastClientVersionIncrementTransactionID) {
return this._libraryClientVersion;
}
let clientVersion = await Zotero.DB.valueQueryAsync(
"UPDATE libraries SET clientVersion = clientVersion + 1 WHERE libraryID=? RETURNING clientVersion",
[this.libraryID]
);
this._libraryClientVersion = clientVersion;
this._lastClientVersionIncrementTransactionID = transactionID;
return clientVersion;
};

View file

@ -86,6 +86,10 @@ Zotero.defineProperty(Zotero.Search.prototype, 'version', {
get: function () { return this._get('version'); },
set: function (val) { return this._set('version', val); }
});
Zotero.defineProperty(Zotero.Search.prototype, 'clientVersion', {
get: function() { return this._get('clientVersion'); },
set: function(val) { return this._set('clientVersion', val); }
});
Zotero.defineProperty(Zotero.Search.prototype, 'synced', {
get: function () { return this._get('synced'); },
set: function (val) { return this._set('synced', val); }

View file

@ -36,6 +36,7 @@ Zotero.Searches = function () {
libraryID: "O.libraryID",
key: "O.key",
version: "O.version",
clientVersion: "O.clientVersion",
synced: "O.synced",
deleted: "DS.savedSearchID IS NOT NULL AS deleted",
}

View file

@ -588,6 +588,7 @@ Zotero.DBConnection.prototype.requireTransaction = function () {
if (!this._transactionID) {
throw new Error("Not in transaction");
}
return this._transactionID;
};

View file

@ -3531,6 +3531,14 @@ Zotero.Schema = new function () {
await Zotero.DB.queryAsync("UPDATE groups SET version = 0");
}
else if (i == 126) {
let clientVersionTables = ['items', 'collections', 'savedSearches', 'libraries', 'groups'];
for (let table of clientVersionTables) {
await Zotero.DB.queryAsync(`ALTER TABLE ${table} ADD COLUMN clientVersion INT NOT NULL DEFAULT 0`);
}
}
// If breaking compatibility or doing anything dangerous, clear minorUpdateFrom
}

View file

@ -154,7 +154,7 @@ class LocalAPIEndpoint {
return this.makeResponse(400, 'text/plain', `Invalid 'since' value '${requestData.searchParams.get('since')}'`);
}
if (since !== 0) {
response.data = response.data.filter(dataObject => dataObject.version > since);
response.data = response.data.filter(dataObject => dataObject.clientVersion > since);
}
}
@ -226,8 +226,8 @@ class LocalAPIEndpoint {
'Link': Object.entries(links).map(([rel, url]) => `<${url}>; rel="${rel}"`).join(', ')
};
let lastModifiedVersion = dataIsArray
? Zotero.Libraries.get(requestData.libraryID).libraryVersion
: response.data.version;
? Zotero.Libraries.get(requestData.libraryID).clientVersion
: response.data.clientVersion;
if (lastModifiedVersion !== undefined) {
headers['Last-Modified-Version'] = lastModifiedVersion;
}

View file

@ -1,4 +1,4 @@
-- 125
-- 126
-- Copyright (c) 2009 Center for History and New Media
-- George Mason University, Fairfax, Virginia, USA
@ -164,6 +164,7 @@ CREATE TABLE items (
libraryID INT NOT NULL,
key TEXT NOT NULL,
version INT NOT NULL DEFAULT 0,
clientVersion INT NOT NULL DEFAULT 0,
synced INT NOT NULL DEFAULT 0,
UNIQUE (libraryID, key),
FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE
@ -295,6 +296,7 @@ CREATE TABLE collections (
libraryID INT NOT NULL,
key TEXT NOT NULL,
version INT NOT NULL DEFAULT 0,
clientVersion INT NOT NULL DEFAULT 0,
synced INT NOT NULL DEFAULT 0,
UNIQUE (libraryID, key),
FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE,
@ -351,6 +353,7 @@ CREATE TABLE savedSearches (
libraryID INT NOT NULL,
key TEXT NOT NULL,
version INT NOT NULL DEFAULT 0,
clientVersion INT NOT NULL DEFAULT 0,
synced INT NOT NULL DEFAULT 0,
UNIQUE (libraryID, key),
FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE
@ -395,6 +398,7 @@ CREATE TABLE libraries (
editable INT NOT NULL,
filesEditable INT NOT NULL,
version INT NOT NULL DEFAULT 0,
clientVersion INT NOT NULL DEFAULT 0,
storageVersion INT NOT NULL DEFAULT 0,
lastSync INT NOT NULL DEFAULT 0,
archived INT NOT NULL DEFAULT 0,
@ -412,6 +416,7 @@ CREATE TABLE groups (
name TEXT NOT NULL,
description TEXT NOT NULL,
version INT NOT NULL,
clientVersion INT NOT NULL DEFAULT 0,
FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE
);

View file

@ -650,7 +650,7 @@ var modifyDataObject = function (obj, params = {}, saveOptions) {
default:
obj.name = params.name !== undefined ? params.name : Zotero.Utilities.randomString();
}
return obj.saveTx(saveOptions);
return obj.save({ ...saveOptions, tx: !Zotero.DB.inTransaction() });
};
/**

View file

@ -52,6 +52,61 @@ describe("Zotero.DataObject", function () {
})
})
describe("#clientVersion", function () {
it("should be set to library clientVersion after creating object", async function () {
for (let type of types) {
let obj = await createDataObject(type);
assert.equal(obj.clientVersion, obj.library.clientVersion);
await obj.eraseTx();
}
});
it("should increase after modifying object", async function () {
for (let type of types) {
let obj = await createDataObject(type);
let clientVersion = obj.clientVersion;
await modifyDataObject(obj);
assert.isAbove(obj.clientVersion, clientVersion);
assert.equal(obj.clientVersion, obj.library.clientVersion);
await obj.eraseTx();
}
});
it("should increase once per library per transaction", async function () {
for (let type of types) {
let obj1 = await createDataObject(type);
let obj2 = await createDataObject(type);
let group = await getGroup();
let obj3 = await createDataObject(type, { libraryID: group.libraryID });
assert.isBelow(obj1.clientVersion, obj2.clientVersion);
assert.equal(obj2.clientVersion, Zotero.Libraries.userLibrary.clientVersion);
await modifyDataObject(obj1);
assert.isAbove(obj1.clientVersion, obj2.clientVersion);
assert.equal(obj1.clientVersion, Zotero.Libraries.userLibrary.clientVersion);
let libraryVersionBefore = Zotero.Libraries.userLibrary.clientVersion;
let groupVersionBefore = group.clientVersion;
await Zotero.DB.executeTransaction(async () => {
await modifyDataObject(obj1);
await modifyDataObject(obj2);
await modifyDataObject(obj3);
});
assert.equal(obj1.clientVersion, Zotero.Libraries.userLibrary.clientVersion);
assert.equal(obj1.clientVersion, obj2.clientVersion);
assert.notEqual(obj1.clientVersion, libraryVersionBefore);
assert.equal(obj3.clientVersion, group.clientVersion);
assert.notEqual(obj3.clientVersion, groupVersionBefore);
}
});
});
describe("#synced", function () {
it("should be set to false after creating object", async function () {
for (let type of types) {

View file

@ -63,6 +63,27 @@ describe("Zotero.Library", function () {
});
});
describe("#clientVersion", function () {
it("should be settable to increasing values", function () {
let library = new Zotero.Library();
assert.throws(() => library.clientVersion = -2);
assert.throws(() => library.clientVersion = "a");
assert.throws(() => library.clientVersion = 1.1);
assert.doesNotThrow(() => library.clientVersion = 0);
assert.doesNotThrow(() => library.clientVersion = 5);
});
it("should not be possible to decrement", function () {
let library = new Zotero.Library();
library.clientVersion = 5;
assert.throws(() => library.clientVersion = 0);
});
it("should not be possible to set to -1", function () {
let library = new Zotero.Library();
library.clientVersion = 5;
assert.throws(() => library.clientVersion = -1);
});
});
describe("#editable", function () {
it("should return editable status", function () {
let library = Zotero.Libraries.get(Zotero.Libraries.userLibraryID);

View file

@ -325,11 +325,25 @@ describe("Local API Server", function () {
describe("?since", function () {
it("should filter the results", async function () {
let { response: response1 } = await apiGet('/users/0/items?since=' + (Zotero.Libraries.userLibrary.libraryVersion + 1));
let version = Zotero.Libraries.userLibrary.clientVersion;
let { response: response1 } = await apiGet('/users/0/items?since=' + version);
assert.isEmpty(response1);
let { response: response2 } = await apiGet('/users/0/items?since=0');
assert.lengthOf(response2, allItems.length);
let tempItem = await createDataObject('item');
let { response: response3 } = await apiGet('/users/0/items?since=' + version);
assert.lengthOf(response3, 1);
assert.equal(response3[0].key, tempItem.key);
assert.equal(response3[0].version, tempItem.clientVersion);
assert.equal(tempItem.clientVersion, version + 1);
await tempItem.eraseTx();
let { response: response4 } = await apiGet('/users/0/items?since=' + version);
assert.lengthOf(response4, 0);
});
});