All formats

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

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:

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"}].

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>}:

typepayload
text, urlstring
numbernumber
measurementnumber in the canonical unit (mm, g, ml)
date"YYYY-MM-DD"
currency{"minorUnits": 645050, "currency": "CHF"}
dropdownoption ID string
multiple_choicearray of option ID strings in the order of the field's options, at least one, each once (decision 0042)
ratinginteger 0…ratingMax
checkboxboolean
quantity{"amount": 12, "unit": "<option id>"}
relationitem 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:

library_meta keys

keyvalue
wishlist_template_idID of the template whose fields every wish gets. Absent: builtin.wishlist.
next_item_numberThe next inventory number to give (version 13), as decimal text. Absent: one above the largest item.number.
link_previewsThe "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.