Catalog schema
Status: draft until app version 1.0 ships. Since version 2, every change is a new numbered migration, because test libraries exist on devices; the golden sample library stays a version 1 catalog, so opening it runs every migration. Source of truth: KuriosaStore/Library/CatalogSchema.swift; the SQL below must stay identical. The version is stored in PRAGMA user_version. Readers must refuse catalogs with a newer version. Migrations run in order inside one transaction.
Conventions
- All tables use
STRICTmode. Foreign keys are enforced (PRAGMA foreign_keys = ON). - IDs are lowercase UUID text and never change.
- Timestamps (
*_at) are ISO 8601 UTC with milliseconds:2026-09-29T15:04:05.123Z. - Calendar days (
purchase_date,date,due_date) areYYYY-MM-DD. - Money is two columns:
*_minor(INTEGER minor units) and*_currency(ISO 4217 code). Both are NULL or both are set. - Measurements inside
item.fieldsare in SI units: mm, g, ml. collection.parent_idnests collections to any depth (no cycles); each collection has its owntemplate_id.- Deletion is soft (
deleted_atset) for items, collections, locations, media, and sets, so it can be undone. Relations (and, until version 11, status events) are removed physically; their full content is kept in the history. - "Delete for good" (decision 0032, issue #43) removes
mediarows physically when they are deleted or belong to a deleted item, without a history entry, and setsitem.primary_photo_id,item.back_photo_id,collection.cover_media_id, andcollection.cover_picture_idtoNULLwhere they named a deleted media file. History entries about those rows stay; undoing one of them fails (CatalogError.deletedForGood). Since version 14 each deleted ID is noted aslibrary_metakeyremoved:<id>, so another copy of the library removes it too (decision 0035). - Enumerated text values are listed in the
CHECKconstraints. relation.type:part_ofpoints from the component (from_item_id) to the larger item (to_item_id); part-of links form a forest (no loops).mounted_onis a neutral link with no direction, shown as "Linked with" on both items (RelationType.linked, issue #21); the name is kept from when it still meant "mounted on", so older libraries need no migration. A neutral link exists at most once per pair of items, in either direction, and is not part of the loop check.
Version 1
CREATE TABLE library_meta (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
) STRICT;
CREATE TABLE template (
id TEXT PRIMARY KEY NOT NULL,
version INTEGER NOT NULL,
json TEXT NOT NULL CHECK (json_valid(json)),
updated_at TEXT NOT NULL
) STRICT;
CREATE TABLE collection (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
parent_id TEXT REFERENCES collection (id),
template_id TEXT NOT NULL REFERENCES template (id),
sort_index INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
) STRICT;
CREATE INDEX collection_parent ON collection (parent_id);
CREATE TABLE location (
id TEXT PRIMARY KEY NOT NULL,
parent_id TEXT REFERENCES location (id),
name TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
) STRICT;
CREATE INDEX location_parent ON location (parent_id);
CREATE TABLE tag (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
) STRICT;
CREATE TABLE item (
id TEXT PRIMARY KEY NOT NULL,
collection_id TEXT NOT NULL REFERENCES collection (id),
kind TEXT NOT NULL CHECK (kind IN ('owned', 'wish')),
title TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('in_collection', 'lent_out', 'at_service', 'sold', 'given_away', 'lost')),
location_id TEXT REFERENCES location (id),
purchase_date TEXT,
purchase_price_minor INTEGER,
purchase_price_currency TEXT,
source TEXT,
estimated_value_minor INTEGER,
estimated_value_currency TEXT,
condition TEXT CHECK (condition IN ('mint', 'excellent', 'very_good', 'good', 'fair', 'poor')),
notes TEXT,
primary_photo_id TEXT,
wish_priority INTEGER,
wish_target_minor INTEGER,
wish_target_currency TEXT,
fields TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(fields)),
wish_fields TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(wish_fields)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
) STRICT;
CREATE INDEX item_collection ON item (collection_id);
CREATE INDEX item_location ON item (location_id);
CREATE TABLE item_tag (
item_id TEXT NOT NULL REFERENCES item (id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tag (id) ON DELETE CASCADE,
PRIMARY KEY (item_id, tag_id)
) STRICT;
CREATE INDEX item_tag_tag ON item_tag (tag_id);
CREATE TABLE media (
id TEXT PRIMARY KEY NOT NULL,
item_id TEXT NOT NULL REFERENCES item (id),
kind TEXT NOT NULL CHECK (kind IN ('photo', 'document')),
content_type TEXT NOT NULL,
filename TEXT,
byte_count INTEGER NOT NULL,
width INTEGER,
height INTEGER,
sort_index INTEGER NOT NULL DEFAULT 0,
caption TEXT,
cutout_content_type TEXT,
cutout_width INTEGER,
cutout_height INTEGER,
shows_cutout INTEGER NOT NULL DEFAULT 0 CHECK (shows_cutout IN (0, 1)),
created_at TEXT NOT NULL,
deleted_at TEXT
) STRICT;
CREATE INDEX media_item ON media (item_id);
CREATE TABLE relation (
id TEXT PRIMARY KEY NOT NULL,
from_item_id TEXT NOT NULL REFERENCES item (id),
to_item_id TEXT NOT NULL REFERENCES item (id),
type TEXT NOT NULL CHECK (type IN ('mounted_on', 'part_of')),
created_at TEXT NOT NULL,
UNIQUE (from_item_id, to_item_id, type)
) STRICT;
CREATE TABLE item_set (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
) STRICT;
CREATE TABLE item_set_member (
set_id TEXT NOT NULL REFERENCES item_set (id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES item (id),
PRIMARY KEY (set_id, item_id)
) STRICT;
CREATE TABLE status_event (
id TEXT PRIMARY KEY NOT NULL,
item_id TEXT NOT NULL REFERENCES item (id),
status TEXT NOT NULL CHECK (status IN ('in_collection', 'lent_out', 'at_service', 'sold', 'given_away', 'lost')),
date TEXT NOT NULL,
counterparty TEXT,
amount_minor INTEGER,
amount_currency TEXT,
due_date TEXT,
notes TEXT,
created_at TEXT NOT NULL
) STRICT;
CREATE INDEX status_event_item ON status_event (item_id);
CREATE TABLE item_search (
item_id TEXT PRIMARY KEY NOT NULL REFERENCES item (id) ON DELETE CASCADE,
text TEXT NOT NULL,
sensitive_text TEXT NOT NULL
) STRICT;
CREATE TABLE history (
id TEXT PRIMARY KEY NOT NULL,
batch_id TEXT NOT NULL,
occurred_at TEXT NOT NULL,
actor TEXT NOT NULL,
entity TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete', 'restore')),
changes TEXT NOT NULL CHECK (json_valid(changes)),
undoes_id TEXT
) STRICT;
CREATE INDEX history_entity ON history (entity_id);
CREATE INDEX history_batch ON history (batch_id);
CREATE INDEX history_undoes ON history (undoes_id);
Version 2
Own export templates (decision 0019). Built-in export templates ship with the app and are not stored.
CREATE TABLE export_template (
id TEXT PRIMARY KEY NOT NULL,
json TEXT NOT NULL CHECK (json_valid(json)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) STRICT;
Rows are listed by created_at. Changes to export templates are not recorded in the history (like collection templates); deleting one removes the row.
Version 3
Existing codes (barcodes, QR codes) linked to items (decision 0020, labels.md).
CREATE TABLE item_code (
id TEXT PRIMARY KEY NOT NULL,
item_id TEXT NOT NULL REFERENCES item (id),
payload TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
) STRICT;
CREATE INDEX item_code_item ON item_code (item_id);
Linking and removing a code are recorded in the history (entity item_code; removal is physical, its content is kept in the history, like relations).
Version 4
Photo edits (decision 0021): how a photo is shown, applied when it is drawn. The files are never re-encoded.
ALTER TABLE media ADD COLUMN rotation INTEGER NOT NULL DEFAULT 0 CHECK (rotation IN (0, 90, 180, 270));
ALTER TABLE media ADD COLUMN crop_x REAL;
ALTER TABLE media ADD COLUMN crop_y REAL;
ALTER TABLE media ADD COLUMN crop_width REAL;
ALTER TABLE media ADD COLUMN crop_height REAL;
See "Media edits" below for how readers apply them. Edits are recorded in the history like other media changes (paths rotation and crop, the crop as an object {"x", "y", "width", "height"} or null).
Version 5
The photo of a flat object's back (reverse), shown next to the front on its tray (design review phase 2, the "lying" showcase).
ALTER TABLE item ADD COLUMN back_photo_id TEXT;
back_photo_id names a photo of the same item, or is NULL. It is never the same photo as primary_photo_id (the front): making the back the primary photo clears it, and a primary photo chosen as the back is replaced by the next photo. Deleting the back photo sets it to NULL. Changes are recorded in the history like primary_photo_id (path backPhotoID).
Photo order
An item's photos are shown by media.sort_index, then created_at. The user can change the order (issue #23): the photos get the positions 0, 1, 2, … in the new order, and the first photo that is not the back becomes primary_photo_id, in one history batch. Choosing a main photo moves it to position 0. Documents keep their own sort_index values; photos and documents are never ordered together.
Collection order
Collections are listed by collection.sort_index, then name. A new collection gets the highest index plus one, so the own order starts as the order of creation. Arranging the collections on the Collections tab (issue #38) gives the collections of one parent (the top level for parent_id NULL) the positions 0, 1, 2, … in the new order, in one history batch. Indices are compared only among siblings; gaps from collections elsewhere do not matter. The other orders of the tab (name, number of items, value, recently changed) and its filters are device settings and are not stored in the catalog.
Version 6
Items marked as favorites with the heart (issue #28).
ALTER TABLE item ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0, 1));
favorite is 1 for a favorite piece or wish, else 0; existing items start at 0. Changes are recorded in the history (path isFavorite) and can be undone.
Version 7
The photo a collection's card shows, chosen by the user (issue #22).
ALTER TABLE collection ADD COLUMN cover_media_id TEXT;
cover_media_id names a photo of a piece in the collection or one of its sub-collections, or is NULL (Kuriosa chooses: the most valuable held piece with a photo). A chosen photo counts only while it and its piece are not deleted, the piece is still in the collection's tree and something of it is left (not sold, given away, or lost, and not used up to a count of 0; issue #81), and the photo is not a coin's back; otherwise the card goes back to choosing on its own, and the stored value stays until the user chooses again. Recorded in the history (path coverPhotoID).
Version 8
Purchases after the first one, for pieces bought bit by bit (issue #25, decision 0027). Dropped in version 11: they are journal entries now.
ALTER TABLE item ADD COLUMN purchases TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(purchases));
The first purchase stays in purchase_date, purchase_price_*, and source. See item.purchases under JSON columns. Changes are recorded in the history (path morePurchases).
Version 9
What was used up, sold, or given away from quantity fields that count down (issue #26, decision 0028). Dropped in version 11: they are journal entries now.
ALTER TABLE item ADD COLUMN uses TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(uses));
See item.uses under JSON columns. Changes are recorded in the history (path uses).
Version 10
The backup history (issue #34): every backup and restore with its result.
CREATE TABLE backup_event (
id TEXT PRIMARY KEY NOT NULL,
occurred_at TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('backup', 'automatic_backup', 'restore', 'undo_restore')),
file_name TEXT,
folder TEXT,
item_count INTEGER,
photo_count INTEGER,
byte_count INTEGER,
succeeded INTEGER NOT NULL CHECK (succeeded IN (0, 1)),
reason TEXT
) STRICT;
Entries are only ever added, never changed, and have no item history or undo. folder is the name the person knows ("iCloud Drive › Kuriosa"), not a path. A backup's own entry is added after the file is written, so it is in the next backup, not in itself. A restore adds the entries of the replaced collection that the restored one lacks (same id), then its own entry; undoing a restore does the same the other way.
Version 11
One journal per piece (issue #41, decision 0031): later purchases, use and partial sales, and status changes become entries in item.events; the first purchase gets a count.
ALTER TABLE item ADD COLUMN purchase_quantity REAL;
ALTER TABLE item ADD COLUMN events TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(events));
UPDATE item SET events = (
SELECT json_group_array(json(entry)) FROM (
SELECT json_patch('{}', json_object(
'id', p.value ->> '$.id', 'kind', 'purchase', 'date', p.value ->> '$.date',
'price', json(p.value -> '$.price'), 'counterparty', p.value ->> '$.source',
'note', p.value ->> '$.note')) AS entry, 0 AS part, p.key AS position
FROM json_each(item.purchases) AS p
UNION ALL
SELECT json_patch('{}', json_object(
'id', u.value ->> '$.id', 'kind', u.value ->> '$.kind', 'date', u.value ->> '$.date',
'quantity', u.value ->> '$.amount', 'price', json(u.value -> '$.price'),
'counterparty', u.value ->> '$.counterparty', 'note', u.value ->> '$.note')), 1, u.key
FROM json_each(item.uses) AS u
UNION ALL
SELECT json_patch('{}', json_object(
'id', s.id, 'kind', CASE s.status WHEN 'in_collection' THEN 'returned' ELSE s.status END, 'date', s.date,
'price', CASE WHEN s.amount_minor IS NULL THEN NULL
ELSE json_object('minorUnits', s.amount_minor, 'currency', s.amount_currency) END,
'counterparty', s.counterparty, 'dueDate', s.due_date, 'note', s.notes)), 2, s.created_at
FROM status_event AS s WHERE s.item_id = item.id
ORDER BY part, position
)
);
UPDATE item SET purchase_quantity = (
SELECT item.fields ->> ('$.' || (u.value ->> '$.fieldKey') || '.value.amount') FROM json_each(item.uses) AS u LIMIT 1
)
WHERE json_array_length(uses) > 0;
UPDATE item SET purchase_quantity = (
SELECT item.fields ->> ('$.' || (f.value ->> '$.key') || '.value.amount')
FROM collection AS c JOIN template AS t ON t.id = c.template_id, json_each(t.json, '$.fields') AS f
WHERE c.id = item.collection_id AND f.value ->> '$.countsDown' = 1
AND item.fields ->> ('$.' || (f.value ->> '$.key') || '.type') = 'quantity'
LIMIT 1
)
WHERE purchase_quantity IS NULL;
UPDATE item SET purchase_quantity = fields ->> '$.bottles.value.amount'
WHERE purchase_quantity IS NULL AND fields ->> '$.bottles.type' = 'quantity'
AND collection_id IN (SELECT id FROM collection WHERE template_id = 'builtin.wine');
UPDATE template SET json = json_set(json, '$.quantity', json((
SELECT json_patch('{}', json_object('one', json(f.value -> '$.options[0].label'), 'other', json(f.value -> '$.options[0].label')))
FROM json_each(template.json, '$.fields') AS f WHERE f.value ->> '$.countsDown' = 1 LIMIT 1
)))
WHERE EXISTS (SELECT 1 FROM json_each(template.json, '$.fields') AS f WHERE f.value ->> '$.countsDown' = 1);
UPDATE item SET purchase_quantity = NULL WHERE purchase_quantity <= 0;
ALTER TABLE item DROP COLUMN purchases;
ALTER TABLE item DROP COLUMN uses;
DROP TABLE status_event;
purchase_quantity is how many were bought the first time, in the collection's unit (template.quantity), more than zero or NULL. The first purchase (purchase_date, purchase_price_*, source, purchase_quantity) and item.events are the piece's journal; status is set from it whenever it changes (decision 0031). The value of a quantity field that counted down (version 9) was what was bought, so it becomes the first purchase's count, on every piece of such a collection, whether something was used or not; a built-in wine's bottles become its count (the built-in wine template, version 3, counts bottles and hides that field). A value of zero or less gives no count. Changes are recorded in the history (paths events and purchaseQuantity). Older history entries with the entity status_event stay readable; the records they describe are gone, so they are not undone.
Version 12
A picture of the person's own on a collection's card (issue #42), for example the whole shelf.
ALTER TABLE collection ADD COLUMN cover_picture_id TEXT;
cover_picture_id names a media file of the library (media/<id>.enc with its thumbnail media/<id>.thumb.enc, see library-format.md) that has no row in media: it belongs to the collection, not to a piece, so it is never among a piece's photos or in exports. It is a JPEG without metadata like every photo and is shown whole, in a mat, never cut out, rotated, or cropped. While it is set, cover_media_id is NULL; choosing a piece's photo or letting Kuriosa choose sets it back to NULL. A replaced picture's file stays in the package, so undo can bring it back. Recorded in the history (path coverPictureID).
Version 13
Every piece's inventory number (issue #50), shown as K-0001.
ALTER TABLE item ADD COLUMN number INTEGER CHECK (number > 0);
UPDATE item SET number = (
SELECT numbered.position FROM (
SELECT id, row_number() OVER (ORDER BY created_at, rowid) AS position
FROM item WHERE kind = 'owned' AND deleted_at IS NULL
) AS numbered
WHERE numbered.id = item.id
)
WHERE kind = 'owned' AND deleted_at IS NULL;
CREATE UNIQUE INDEX item_number ON item (number);
INSERT INTO library_meta (key, value) VALUES ('next_item_number', CAST((SELECT coalesce(max(number), 0) + 1 FROM item) AS TEXT))
ON CONFLICT (key) DO UPDATE SET value = excluded.value;
UPDATE item_search SET text = ltrim(text || ' k ' || (SELECT printf('%04d', number) FROM item WHERE item.id = item_search.item_id))
WHERE item_id IN (SELECT id FROM item WHERE number IS NOT NULL);
number is a running number, unique in the library (decision 0034). The pieces there are (owned, not deleted, sold ones too) are numbered in the order they were added; wishes and deleted pieces get none. Afterwards a writer gives a piece the next number when it is created, imported, or bought, or when a deleted piece without a number comes back: the larger of library_meta.next_item_number and one above every number in item (deleted rows included), and it stores the number after it in next_item_number. A number is never changed by an edit and never given twice, also not after the piece is deleted or its addition is undone; a piece that goes back to the wishlist keeps its number. Readers show it as K- and at least four digits (K-0001, K-12345, see ItemNumber) and show none for wishes. Recorded in the history (path number).
Version 14
Which device made each change (issue #52, decision 0035), for a library several iPhones share.
ALTER TABLE history ADD COLUMN device TEXT;
device is a random ID each device gives itself once (a lowercase UUID on iOS, kept in the app's settings, never sent anywhere). Entries written before version 14, or by a writer without a device, have NULL. Readers show changes whose device is set and differs from their own as made "On another iPhone". Combining two copies of a catalog (library-format.md, section 10) joins the history rows with their device.
Version 15
Reminders on a piece's dates (issue #54, decision 0036).
ALTER TABLE item ADD COLUMN reminders TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(reminders));
Existing pieces get an empty list. See item.reminders below. Recorded in the history (path reminders, the whole list).
Version 16
The previews of links on pieces (issue #88, decision 0043): the page's title, the site's name, and its picture.
CREATE TABLE link_preview (
id TEXT PRIMARY KEY NOT NULL,
item_id TEXT NOT NULL REFERENCES item (id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT,
site_name TEXT,
picture BLOB,
picture_width INTEGER,
picture_height INTEGER,
loaded_at TEXT NOT NULL,
UNIQUE (item_id, url)
) STRICT;
Rows exist only while library_meta.link_previews is on (see below): turning it off deletes every row. url is the link exactly as stored in the piece's field (item.fields or item.wish_fields, type url); one row per piece and link, so the same address in two fields has one preview. Only links of visible fields that are not sensitive, starting with https:// and with a host, get one. title is the page's og:title, twitter:title, or <title>, spaces collapsed, at most 300 characters; site_name its og:site_name or application-name, NULL to show the link's host. picture is a JPEG without metadata, at most 800 px on its long side, transparent parts on white, with its size in pixels. loaded_at is when the page was last loaded, also when it gave nothing (title and picture both NULL): such a row is loaded again when the piece's page is opened at least a day later. A newly loaded preview replaces the row with a new id. Rows of links the piece no longer has are deleted when its page opens or a preview of it is stored. Previews are not recorded in the history, never undone, and never exported.
JSON columns
item.events
The journal after the first purchase, in the order entered: [{"id": "<uuid>", "kind": "used", "date": "2026-10-04", "quantity": 120, "note": "Range day"}]. kind is purchase, used, sold, given_away, lost, lent_out, at_service, or returned. Every key but id and kind is optional and left out when not set: quantity (a number more than zero, in the collection's unit), price (a money object: the price paid, the sale price, or the cost of a service), counterparty (the source, buyer, recipient, borrower, or workshop), dueDate (YYYY-MM-DD, when something lent out or at a service is due back), and note. Readers show the journal newest first (entries without a day last) and work out:
- the count: every
purchasecount (the first purchase'spurchase_quantityincluded) minus everyused,sold,given_away, andlostcount, at least 0; what is away:lent_outandat_servicecounts minusreturnedcounts, at least 0 and at most the count. Entries without a count are left out; without any count there is none; - the price per unit: the prices of the purchases with a price and a count divided by their counts, in the first price's currency, rounded to the minor unit;
- the total purchase price: the first price and every later purchase price in the first price's currency; prices in another currency are not added;
- the status: see decision 0031.
Older history entries hold the lists of versions 8 and 9 (morePurchases: purchases with a source; uses: entries with an amount and a fieldKey); readers take them as purchases and as entries with that count.
item.reminders
Reminders on the piece's dates (version 15), at most one per date, in the order they were set: [{"id": "field:next_inspection", "lead": "week_before", "repeat": "every_2_years"}].
idnames the date:field:<key>for a field of typedateof the collection's template, ordue:<entry id>for thedueDateof anitem.eventsentry (lent_outorat_service).leadis how long before the date the reminder comes:on_the_day,day_before,week_before, ormonth_before(the same day of the previous month, or its last day).repeatis how often the date comes again:monthly,yearly,every_2_years,every_3_years, orevery_5_years, always counted from the date as entered (a month keeps its day or ends on its last day). Left out when it does not repeat; adue:reminder never repeats.
Readers remind (decision 0036) at 9:00 local time on the date (or a repeat of it) minus the lead, for pieces with kind = 'owned', not deleted, and not sold, given_away, or lost. A field: reminder needs the field to be a visible date field with a value; a due: reminder needs its entry with a due date and the piece away (status lent_out or at_service, or some of its count away). Removing an entry, or its due date, removes its reminder. Reminders are never exported.
template.json
A template document, see template-format.md.
export_template.json
An export template, see export.md.
item.fields
Object of field key → tagged value {"type": "<field type>", "value": <payload>}:
| type | payload |
|---|---|
text, url | string |
number | number |
measurement | number in the canonical unit (mm, g, ml) |
date | "YYYY-MM-DD" |
currency | {"minorUnits": 645050, "currency": "CHF"} |
dropdown | option ID string |
multiple_choice | array of option ID strings in the order of the field's options, at least one, each once (decision 0042) |
rating | integer 0…ratingMax |
checkbox | boolean |
quantity | {"amount": 12, "unit": "<option id>"} |
relation | item ID string |
Keys not defined by the current template are kept and ignored. A dropdown field may hold a multiple_choice value and a multiple_choice field a dropdown value, from before the field's type changed; readers take either (see the template format, "Multiple choice fields").
item.wish_fields
Same shape as item.fields, for the fields of the wishlist template (see below). Kept when a wish is bought.
Media cut-outs
A photo may have a cut-out version (the subject on a transparent background, PNG): cutout_content_type, cutout_width, and cutout_height are set, and the files media/<id>.cutout.enc and media/<id>.cutout-thumb.enc exist (see library-format.md). shows_cutout = 1 means readers show the cut-out instead of the original; the original file is never changed.
Media edits
rotation turns the photo clockwise by 0, 90, 180, or 270 degrees. crop_x, crop_y, crop_width, and crop_height are shares (0…1) of the rotated photo, measured from its top-left corner; all four NULL means the whole photo, and a crop never reaches outside 0…1 or below 0.05 on a side. Readers show:
- the original: rotate, then crop;
- the cut-out (when
shows_cutout = 1): rotate only; it is framed around the object.
library_meta keys
| key | value |
|---|---|
wishlist_template_id | ID of the template whose fields every wish gets. Absent: builtin.wishlist. |
next_item_number | The next inventory number to give (version 13), as decimal text. Absent: one above the largest item.number. |
link_previews | The "Link previews" switch (version 16, decision 0043): the time it was last turned on or off, a space, and on or off (2026-10-10T14:00:00.000Z on). Absent: off. |
removed:<id> | A record or file removed without a history entry (decision 0035): a media file deleted for good (<id> is the media ID) or a deleted own export template (<id> is its ID). The value is the time of the removal (timestamp format). Combining copies removes the same in the other copy and never brings it back. |
history.changes
Array of {"path", "old", "new"}, sorted by path. path is a top-level attribute of the record's JSON form (title, purchasePrice, deletedAt, tagIDs, …) or fields.<key>. Missing values are null. For create, all old are null; for a physical delete, all new are null and old holds the complete record. id, createdAt, and updatedAt are never recorded.
Entries written by one user action share a batch_id; undo reverts a whole batch and writes new entries with undoes_id pointing to the undone entries. actor is local for the device's user, and merge for the repairs a merge of two copies makes (decision 0035), which "Undo" of the person's last step leaves alone.
Derived data
item_search holds normalized search text (see the normalization rule in DuplicateDetector), split into normal and sensitive template fields. A piece's normal text includes its inventory number (k 0007, version 13). It can be rebuilt at any time from item and template and is not needed in exports.