Files Versions Developer Guide - edmozley/freeitsm GitHub Wiki

Files versions — Developer Guide

5.2.0 · #303 (from pappas81) · User guide: Files § Versions · Module guide: Files - Developer Guide · Storage: Files storage - Developer Guide

🔑 The one-line version

A file (files_items) points at its current version (current_version_id), and every earlier upload stays in files_versions. In 5.2.0 versions became a feature you can see and act on:

  • a v3 badge on files with more than one version;
  • Version history and Upload new version on a file's right-click menu, with an optional note;
  • Restore and Delete on each version;
  • the same-name choices (New version / Keep both / Overwrite / Skip) and how many versions to keep, both set under Files → Settings → Versions.

All the rules live in includes/services/files_versions.php.

📁 Files

File What
includes/services/files_versions.php FilesVersionsService (settings, saveSettings, restore, deleteVersion, overwrite, applyKeep) and filesPurgeBytesIfUnused(); FILES_CONFLICT_CHOICES, FILES_KEEP_VERSIONS_CHOICES
api/files/upload.php start takes on_conflict (only the offered choices), item_id (Upload new version) and note; finish stores the note, then overwrites or applies keep-N
api/files/item.php restore_version (Upload level) and delete_version (Modify)
api/files/properties.php Each version's note and restored_from
api/files/settings.php ?tab=versions GET / POST, Cap::FILES_VERSIONS
cron/files_versions_tidy.php Job files_versions_tidy - daily, CLI only, needed while keep-N is set
includes/capabilities.php, files/settings/manifest.php Cap::FILES_VERSIONS (files.versions, sensitive) and its tab
files/settings/index.php, assets/js/files-settings.js (v=6) The Versions tab
assets/js/files-desktop.js (v=14), assets/css/files.css (v=13) The same-name dialog, the right-click items, the badge, Properties' Restore / Delete
includes/files/audit.php New audit actions restore_version, delete_version, overwrite

🗄️ Data

-- files_versions: two new columns
note              VARCHAR(255) NULL,   -- what changed, typed with Upload new version
restored_from_no  INT NULL,            -- set = this version is a restore of version N (shares its bytes)

-- files_uploads: the choice and the note travel from start to finish
replace_mode      VARCHAR(10) NULL,    -- 'overwrite' = delete the file's earlier versions on finish
version_note      VARCHAR(255) NULL,

Settings (system_settings, written only by FilesVersionsService::saveSettings()):

Key Values Default
files_conflict_choices comma list of version, rename, overwrite, skip version,rename,skip - Overwrite is off
files_conflict_default the highlighted choice (never skip) version
files_keep_versions one of 0, 2, 3, 5, 10, 20, 50 (a dropdown) - 0 = every version 0

🔁 The same-name flow

// api/files/upload.php - start
$vs = FilesVersionsService::settings($conn);
if ($conflict !== 'ask' && !in_array($conflict, $vs['choices'], true) && !($target > 0 && $conflict === 'version')) {
    filesApiFail('That choice is not offered here - see Files -> Settings -> Versions.');
}
if ($conflict === 'overwrite') filesNeed($conn, $analystId, $folderId, FilesAcl::MODIFY, 'overwrite');
// ... a clash with $conflict === 'ask' answers:
filesApiOk(['conflict' => true, 'item_id' => ..., 'name' => ..., 'choices' => $vs['choices'], 'default' => $vs['default']]);
// assets/js/files-desktop.js - the dialog is drawn from what the server offered
function askConflict(u, name, offer) {
    var choices = (offer && offer.choices) || ['version', 'rename', 'skip'];
    var buttons = choices.map(function (c) { return { label: LABEL[c], value: c, primary: c === dflt, cancel: c === 'skip' }; });
    // ... "Do this for every clash in this upload" remembers the answer for the batch
}

🔑 The server decides what is allowed, not the dialog. A choice the settings do not offer is refused at start, whatever the browser sends. Overwrite needs Modify on the folder, because it deletes versions. It is checked at start and again at finish, in case the grant was removed while a big file was going up.

Upload new version sends item_id. The upload then becomes that file's next version whatever the picked file is called, and it is always allowed with Upload, whatever the same-name settings are.

♻️ Restore, delete, overwrite, keep-N

// Restore: a NEW version that shares the old one's bytes - nothing is lost
INSERT INTO files_versions (item_id, version_no, storage_path, size_bytes, mime_type, sha256, uploaded_by, uploaded_datetime, note, restored_from_no)
VALUES (?, MAX(version_no) + 1, <old version's storage_path>, ..., <old note>, <old version_no>);
UPDATE files_items SET current_version_id = <new id>, size_bytes = ..., mime_type = ...;
  • Delete version: never the current one. The row goes, then filesPurgeBytesIfUnused().
  • Overwrite: every version except the new one goes, and the new one is renumbered 1. The file starts again.
  • Keep N (applyKeep()): rows are ordered current first, then newest. Everything past the first N goes. So the current version always stays, even when it is a restored old number. It runs:
    • on every new version (upload or restore);
    • once a day from the job, for files nobody has touched since the setting was lowered.

🧹 Deleting bytes - the shared-bytes rule

function filesPurgeBytesIfUnused(PDO $conn, string $key): void
{
    if (!filesStorageKeyIsValid($key)) return;
    // ANY files_versions row naming it - another version, a copied file, a binned file's history - keeps it
    $st = $conn->prepare("SELECT 1 FROM files_versions WHERE storage_path = ? LIMIT 1");
    $st->execute([$key]);
    if ($st->fetchColumn()) return;
    // During a storage move the bytes may be in the old place, the new place, or both
    foreach ([filesActiveStore($conn), filesPreviousStore($conn)] as $store) { ... $store->delete($key) ... }
    $conn->prepare("DELETE FROM storage_migration_items WHERE store = 'files' AND object_key = ?")->execute([$key]);
}

🔑 Three things share bytes, and each one is why this check exists:

  • a copied file - its version 1 is the source's storage_path;
  • a restored version - it points at the version it restores;
  • a binned file - its versions stay until a purge.

⚠️ Traps

  • TRAP: never delete bytes because a version row went. Call filesPurgeBytesIfUnused(). It deletes only when nothing at all names the path.
  • TRAP: applyKeep() orders is_current DESC first. Ordering by version_no alone would delete a restored current version whose number is old.
  • TRAP: the same-name choices are enforced at upload.php?action=start, not in the dialog. A browser can send any on_conflict.
  • TRAP: FilesVersionsService::restore() joins a caller's transaction ($own = !$conn->inTransaction()). Opening its own transaction inside another fails with "There is already an active transaction". The test found this.

🧪 Tests

  • tests/files-versions.php (18, test database, temporary storage folder, rolled back):
    • the defaults, and saving refusing Skip alone, an unticked highlighted choice and a keep value not in the list;
    • restore making version 3, which shares version 1's bytes, with version 2 kept;
    • deleting the current version refused;
    • deleting a version whose bytes are still shared keeps them, and deleting one nothing shares removes them;
    • keep 2 of 5 keeping the current (old-numbered) version and the newest;
    • overwrite leaving one version;
    • a copied file's bytes never deleted.
  • A live run on the test install through the real endpoints (15):
    • the tab;
    • the conflict reply offering the settings' choices;
    • Overwrite refused until it is ticked, then leaving one version numbered 1;
    • New version making version 2, and Keep both saving notes (2).txt;
    • Upload new version targeting the file whatever its name, with the note stored on one line;
    • Restore making version 4 and the download giving version 1's contents;
    • deleting the current version refused, deleting version 2 working;
    • keep-2 trimming on the next upload.
  • Chrome: the Versions tab fills in (choices, highlighted, keep list), and the Files desktop starts.

See also: Files - Developer Guide · Files storage - Developer Guide · Scheduled tasks

⚠️ **GitHub.com Fallback** ⚠️