Database schema¶
This reference explains the data-model decisions that matter for Zotero compatibility. It compares altero with the official dataserver and focuses on substantive differences rather than naming conventions.
Adopted from the dataserver¶
Tag type belongs to the tag. tags is unique on
(libraryID, name, type), so a name added by hand and the same name added by a
translator are two rows rather than one tag with two links. altero originally
put type on the item/tag link, which would have merged them and reported one
type for both. Tags also carry an eight-character key, like every other
syncable object.
Three timestamps per object. dateAdded and dateModified are supplied by
the client and round-trip through the API; serverDateModified is set by the
server on every write. Keeping them apart is what makes the serverDateModified
sort trustworthy — a client cannot reorder another client's results by
backdating its own dateModified. Applied to items, collections, saved searches
and tags via the Timestamped mixin in altero/db.py.
Versions start at 1, not 0, matching DEFAULT '1' on every versioned table.
Collections and searches are trashable. The dataserver has
deletedCollections and deletedSearches alongside deletedItems, so the
trash is not an item-only concept.
Deliberate differences¶
The trash is a flag, not a table. The dataserver records trashed objects in
deletedItems(itemID, dateDeleted); altero uses a deleted boolean on the
object. The queries are equivalent and the flag avoids a join on every listing.
The cost is that the deletion time is not kept, which nothing in the v3 API
exposes.
Creators are not deduplicated. The dataserver interns creators in a
creators table and links them through itemCreators, so one person shared by a
thousand items is stored once. altero stores creators inline on the item. This
trades space for simplicity; deduplication can be introduced behind the same
service functions if it ever matters.
Notes and attachments are not separate tables. The dataserver keeps
itemNotes (with a sanitized copy and an 80-character derived title) and
itemAttachments. altero stores both a note's content and an attachment's
storage columns as ordinary field rows in item_fields. Those fields are not in
the published schema, so the set each type accepts is declared in
UNLISTED_FIELDS in services/itemwrites.py rather than derived.
The upside is that one code path stores every field of every item type. The
cost is that nothing at the database level constrains an attachment to have a
linkMode, where a dedicated table would.
Relations are stored per item. The dataserver has a library-scoped
relations(subject, predicate, object) table plus itemRelated for
dc:relation. altero stores (item, predicate, object), which produces the same
relations map in JSON but cannot express a relation whose subject is not an
item in the library.
Sort keys are columns on the item. The dataserver keeps itemSortFields
(sortTitle truncated to 79 characters, plus creatorSummary) in a side table
and joins itemData for date sorting. altero keeps sort_title, sort_creator
and sort_date on items, untruncated, so every sort is a single-table index
scan.
No sharding. shards, shardHosts and shardLibraries have no
counterpart: the dataserver spreads libraries across MySQL servers, and altero
uses one database.
Files are stored by digest, not tracked per library. The dataserver has
storageFiles, storageFileItems, storageFileLibraries and an upload queue,
because it hands uploads to S3 and has to account for them. altero writes the
bytes itself, under <storage>/<first two characters of the digest>/<digest>,
so identical files are stored once and the only table needed is
storage_uploads — one row per authorization, deleted once the upload is
registered. Nothing counts how many items reference a given file, so removing an
item leaves its bytes on disk; a collector that deletes unreferenced files is
not written yet.
The item type schema is not in the database. The dataserver stores item
types, fields and their mappings in master.sql tables (itemTypes, fields,
baseFieldMappings, …). altero reads them from the vendored schema.json
instead, which keeps them in step with the published schema and out of
migrations. Item types and field names are therefore stored as strings rather
than as foreign keys to an ID table.
Tables with no direct counterpart¶
write_tokens records a Zotero-Write-Token for as long as a client might
retry with it, so a repeated request does not create the objects twice. The
dataserver caches these outside the database.
item_fulltext holds the text a client extracted from an attachment, matching
the dataserver's table of the same purpose, without the reindexing bookkeeping
that a hosted service needs.
settings matches the dataserver's, except that the value is stored as JSON
text: the server never interprets a setting, so there is nothing to model.
Delete log¶
syncDeleteLogKeys(libraryID, objectType, key, timestamp, version) backs
/deleted?since=, with objectType one of collection, creator, item,
relation, search, setting, tag or tagName. Its primary key is
(libraryID, objectType, key), so deleting a key that was deleted before
updates the existing row rather than adding a second one. altero mirrors this
shape in deleted_objects.
Concurrency¶
Writes to one library are serialized by taking a row lock on it before the
version precondition is checked and holding it until commit, so a request is
atomic with respect to its library. SELECT ... FOR UPDATE is emitted on
PostgreSQL and dropped on SQLite, which has a single writer anyway. The version
increment itself is computed by the database rather than in Python, so it stays
correct even without the lock.
Without that lock, ten simultaneous creates were measured all receiving version
1, and nine of the ten items were lost — the surviving write overwrote the rest.
tests/test_concurrency.py reproduces this against PostgreSQL.
Two further get-or-create patterns — claiming a write token, and creating a tag named by more than one concurrent request — insert first and let the unique constraint decide, rather than looking and then inserting. With the library lock held these cannot interleave, so the behavior is defense in depth: it keeps them correct independently of the locking strategy above.
SQLite is configured with foreign_keys=ON, WAL and a busy timeout.
BEGIN IMMEDIATE is deliberately not used: it would close the remaining
lock-upgrade hole, but takes the write lock for read-only transactions too, so a
single long read would block every writer. A deployment serving several clients
at once should use PostgreSQL.