NAME
App::FileSync::Admin - server-side repo, client and recovery administration
SYNOPSIS
my $admin = App::FileSync::Admin->new(root => '/srv/filesync');
$admin->repo_create(name => 'docs');
$admin->client_allow(repo => 'docs', client => 'laptop');
my $report = $admin->verify(repo => 'docs', deep => 1);
DESCRIPTION
The library behind filesync-admin. Methods return hashrefs or arrayrefs for the command line to print (or encode as JSON) and raise catalogued errors (see App::FileSync::I18N) on failure.
METHODS
new
Purpose: an administrator for one server root.
Args: optional root (default FILESYNC_ROOT, then /srv/filesync), logger (default from App::FileSync::Settings), language.
Returns: the object.
Side Effects: builds a logger from the settings when none is given.
Usage:
my $admin = App::FileSync::Admin->new(root => $root);
EXAMPLE
my $admin = App::FileSync::Admin->new;
print $admin->root, "\n"; # /srv/filesync unless FILESYNC_ROOT says otherwise
API SPECIFICATION
INPUT
{
root => { type => 'string', optional => 1 },
logger => { type => 'object', optional => 1 },
language => { type => 'string', optional => 1 },
}
OUTPUT
{ type => 'object', isa => 'App::FileSync::Admin' }
MESSAGES
Key Meaning Resolution
args.invalid an argument is malformed fix the call
FORMAL SPECIFICATION
root' = first non-empty ⟨root, FILESYNC_ROOT, "/srv/filesync"⟩
root
Purpose: the server root this object administers.
Args: none.
Returns: the path.
Side Effects: none.
Usage:
my $root = $admin->root;
EXAMPLE
print $admin->root, "\n";
API SPECIFICATION
INPUT
[ { name => 'self', type => 'object' } ]
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
None.
FORMAL SPECIFICATION
root = self.root
repo_create
Purpose: create a repo: its master tree, metadata directories, policy and an empty index.
Args: name (a repo id), optional trash_days, max_deletes, max_delete_percent (non-negative integers) and ignore (patterns; the default ignore list when absent or empty).
Returns: { name, master, config } (the master and config paths).
Side Effects: creates ROOT/master/NAME and ROOT/.filesync/repos/NAME.
Usage:
$admin->repo_create(name => 'docs', trash_days => 60);
EXAMPLE
my $made = $admin->repo_create(name => 'photos', ignore => [ '*.tmp', '/cache/' ]);
print "master at $made->{master}\n";
API SPECIFICATION
INPUT
{
name => { type => 'string', min => 1, max => 64 },
trash_days => { type => 'integer', min => 0, optional => 1 },
max_deletes => { type => 'integer', min => 0, optional => 1 },
max_delete_percent => { type => 'integer', min => 0, optional => 1 },
ignore => { type => 'arrayref', optional => 1 },
}
OUTPUT
{
type => 'hashref',
schema => {
name => { type => 'string' },
master => { type => 'string' },
config => { type => 'string' },
},
}
MESSAGES
Key Meaning Resolution
admin.bad_repo the name is not a valid id letters, digits, . _ - ; at most 64
admin.repo_exists the repo already exists choose another name
io.mkdir / io.write the files cannot be written check permissions
FORMAL SPECIFICATION
ΔFS
pre name ∈ Id ∧ repo_config_path(root, name) ¬in; dom FS
post repo_config_path(root, name) ∈ dom FS' ∧ index(name) = ∅
PSEUDOCODE
validate the name; refuse if the repo exists
start from the default policy; apply the given limits and ignore list
create the master and metadata directories
save the policy and an empty index
repo_list
Purpose: the repos under this root.
Args: none.
Returns: an arrayref of names, sorted.
Side Effects: reads the metadata directory.
Usage:
print "$_\n" for @{ $admin->repo_list };
EXAMPLE
my $repos = $admin->repo_list;
print @{$repos} ? "@{$repos}\n" : "none\n";
API SPECIFICATION
INPUT
[ { name => 'self', type => 'object' } ]
OUTPUT
{ type => 'arrayref' }
MESSAGES
None; a root with no repos gives an empty list.
FORMAL SPECIFICATION
repo_list = sort { n | n ∈ children(root/.filesync/repos) ∧ isdir(n) }
repo_show
Purpose: a repo's policy and where it lives.
Args: $name.
Returns: { config, master, meta, clients }; clients is a sorted arrayref.
Side Effects: reads the repo config.
Usage:
my $info = $admin->repo_show('docs');
EXAMPLE
my $info = $admin->repo_show('docs');
print "trash kept $info->{config}{trash_days} days\n";
API SPECIFICATION
INPUT
[ { name => 'name', type => 'string', min => 1 } ]
OUTPUT
{ type => 'hashref' }
MESSAGES
Key Meaning Resolution
admin.bad_repo the name is not an id check the spelling
config.no_repo the repo does not exist filesync-admin repo list
FORMAL SPECIFICATION
repo_show(n) = ⟨config ↦ load_repo(n), master ↦ master_dir(n), meta ↦ meta_dir(n), clients ↦ sort dom config.clients⟩
client_allow
Purpose: let a client (or replica) sync a repo.
Args: repo, client (a client id), optional role (client, the default, or replica).
Returns: { client, role }.
Side Effects: rewrites the repo config. Allowing an existing client again changes its role.
Usage:
$admin->client_allow(repo => 'docs', client => 'backup', role => 'replica');
EXAMPLE
$admin->client_allow(repo => 'docs', client => 'laptop');
API SPECIFICATION
INPUT
{
repo => { type => 'string', min => 1 },
client => { type => 'string', optional => 1 },
role => { type => 'string', optional => 1, memberof => [ 'client', 'replica' ] },
}
OUTPUT
{ type => 'hashref', schema => { client => { type => 'string' }, role => { type => 'string' } } }
MESSAGES
Key Meaning Resolution
admin.bad_client the client id is not valid letters, digits, . _ - ; at most 64
admin.bad_role the role is not client/replica choose one of those
admin.bad_repo the repo name is not an id check the spelling
config.no_repo the repo does not exist create it first
FORMAL SPECIFICATION
ΔRepoConfig
pre client ∈ Id ∧ role ∈ {client, replica}
clients' = clients ⊕ {client ↦ role}
client_list
Purpose: the clients allowed on a repo.
Args: $repo.
Returns: an arrayref, sorted by id, of { client, role, base }; base is 1 when the server holds that client's base (it has synced at least once).
Side Effects: reads the repo config.
Usage:
for my $c (@{ $admin->client_list('docs') }) { ... }
EXAMPLE
printf "%-12s %s\n", $_->{client}, $_->{role} for @{ $admin->client_list('docs') };
API SPECIFICATION
INPUT
[ { name => 'repo', type => 'string', min => 1 } ]
OUTPUT
{ type => 'arrayref' }
MESSAGES
Key Meaning Resolution
config.no_repo the repo does not exist check the name
FORMAL SPECIFICATION
client_list(r) = sort ⟨ (c, role(c), base(c) ∈ dom FS) | c ∈ dom clients(r) ⟩
client_forget
Purpose: drop the server's base for a client, so its next sync starts afresh.
Args: repo, client.
Returns: { removed => 0|1, path }.
Side Effects: removes the client's base file. The client stays allowed.
Usage:
$admin->client_forget(repo => 'docs', client => 'laptop');
EXAMPLE
my $done = $admin->client_forget(repo => 'docs', client => 'old-laptop');
print $done->{removed} ? "forgot\n" : "nothing to forget\n";
API SPECIFICATION
INPUT
{
repo => { type => 'string', min => 1 },
client => { type => 'string', min => 1 },
}
OUTPUT
{ type => 'hashref', schema => { removed => { type => 'integer' }, path => { type => 'string' } } }
MESSAGES
Key Meaning Resolution
store.bad_client the client id is not valid check the spelling
io.remove the base cannot be removed check permissions
FORMAL SPECIFICATION
ΔFS
FS' = {base_path(r, c)} ⩤ FS
removed = 1 ⇔ base_path(r, c) ∈ dom FS
journal_dir
Purpose: where a repo's journal lives.
Args: $repo.
Returns: ROOT/.filesync/repos/REPO/journal.
Side Effects: reads the repo config.
Usage:
my $dir = $admin->journal_dir('docs');
EXAMPLE
print $admin->journal_dir('docs'), "\n";
API SPECIFICATION
INPUT
[ { name => 'repo', type => 'string', min => 1 } ]
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
Key Meaning Resolution
config.no_repo the repo does not exist check the name
FORMAL SPECIFICATION
journal_dir(r) = meta_dir(r) / "journal"
journal_read
Purpose: the journal's events, filtered.
Args: repo; optional filters client (id), since and until (YYYY-MM-DD or epoch, inclusive), exits (arrayref of exit codes; selects session outcomes), warnings (sessions that kept conflict copies) and limit (the newest N).
Returns: an arrayref of event hashrefs, oldest first.
Side Effects: reads every monthly journal file. A torn final line from a killed run is skipped.
Usage:
my $events = $admin->journal_read(repo => 'docs', since => '2026-10-01', limit => 50);
EXAMPLE
for my $event (@{ $admin->journal_read(repo => 'docs', exits => [ 1, 2 ]) }) {
print "$event->{ts} $event->{client} exit $event->{exit}\n";
}
API SPECIFICATION
INPUT
{
repo => { type => 'string', min => 1 },
client => { type => 'string', optional => 1 },
since => { type => 'string', optional => 1 },
until => { type => 'string', optional => 1 },
exits => { type => 'arrayref', optional => 1 },
warnings => { type => 'boolean', optional => 1 },
limit => { type => 'integer', optional => 1 },
}
OUTPUT
{ type => 'arrayref' }
MESSAGES
Key Meaning Resolution
when.bad since/until is not a date use YYYY-MM-DD or an epoch
io.read a journal file cannot be read check permissions
FORMAL SPECIFICATION
let E = ⟨ e | f ∈ sort(journal files), line ∈ f, e = json(line) ∈ HASH ⟩
journal_read = last limit (sort_by ts (filter(E)))
PSEUDOCODE
read every journal file in name (month) order, skipping torn lines
keep events for the client, inside since..until
keep session outcomes with a wanted exit code, if asked
keep sessions that kept conflict copies, if asked
sort by time; keep the newest limit
verify
Purpose: rehash the master against its index and report what no longer agrees.
Args: repo, optional deep (hash every file) and sample (hash N random files).
Returns: the App::FileSync::Verify report, plus repo and master.
Side Effects: reads the master tree; logs a debug summary.
Usage:
my $report = $admin->verify(repo => 'docs', deep => 1);
EXAMPLE
my $report = $admin->verify(repo => 'docs', sample => 20);
print $report->{ok} ? "clean\n" : "drift\n";
API SPECIFICATION
INPUT
{
repo => { type => 'string', min => 1 },
deep => { type => 'boolean', optional => 1 },
sample => { type => 'integer', min => 0, optional => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
Key Meaning Resolution
config.no_repo the repo does not exist check the name
verify.* see App::FileSync::Verify
FORMAL SPECIFICATION
verify(r) = Verify(master_dir(r), index(r), ignore(r)) ⊕ ⟨repo ↦ r, master ↦ master_dir(r)⟩
reindex
Purpose: rebuild master.json from the tree, hashing every file.
Used after a manual repair (a rollback from a backup, say) so the index describes the restored master without waiting for the next client session. The old index is deliberately not reused: a restored file can keep its size and mtime while its content differs, which is exactly the case reindex exists for.
Args: repo, optional dry_run.
Returns: { repo, master, index, files, dirs, bytes, skipped, dry_run, written }.
Side Effects: holds the repo lock (the same one the server takes), so it never races a session; writes the index and a journal event unless dry_run.
Usage:
my $result = $admin->reindex(repo => 'docs');
EXAMPLE
my $plan = $admin->reindex(repo => 'docs', dry_run => 1);
print "would index $plan->{files} files\n";
API SPECIFICATION
INPUT
{
repo => { type => 'string', min => 1 },
dry_run => { type => 'boolean', optional => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
Key Meaning Resolution
admin.locked a sync session holds the repo lock retry when it has finished
config.no_repo the repo does not exist check the name
FORMAL SPECIFICATION
ΔFS
pre lock(r) is free
¬dry_run ⇒ index'(r) = scan(master_dir(r))
PSEUDOCODE
open the repo and take its lock
walk the master, hashing everything
unless dry run: save the index and journal the reindex
log and report the counts
restore
Purpose: put a path back from the server trash: into the master by default, or into to as an export.
Args: repo, path (the relative path as synced), optional to (a directory), date (YYYY-MM-DD; the newest version from that day), list (only list the versions).
Returns: with list, { repo, path, versions } (rows from "version_info" in App::FileSync::Trash); otherwise { repo, path, from, to, date, sha256, into_master }.
Side Effects: copies the version out (the trash entry is kept, so this is repeatable); when restoring into the master, rebuilds the index so verify and the next sync agree; journals the restore.
Usage:
$admin->restore(repo => 'docs', path => 'notes/a.txt');
EXAMPLE
my $done = $admin->restore(repo => 'docs', path => 'a.txt', to => '/tmp/out', date => '2026-10-20');
print "restored to $done->{to}\n";
API SPECIFICATION
INPUT
{
repo => { type => 'string', min => 1 },
path => { type => 'string', min => 1 },
to => { type => 'string', optional => 1 },
date => { type => 'string', optional => 1 },
list => { type => 'boolean', optional => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
Key Meaning Resolution
admin.unsafe_path the path fails validate_relpath fix the path
admin.no_versions nothing of that path is in the trash check "restore --list"
admin.bad_date the date is not YYYY-MM-DD fix it
admin.no_version_on no version from that day pick a listed date
admin.not_dir the export target is not a directory choose another
admin.exists the destination exists move it aside first
FORMAL SPECIFICATION
ΔFS
pre path ∈ SafePath ∧ versions(path) ≠ ⟨⟩ ∧ dest ¬in; dom FS
post tree'(dest) = tree(chosen) ∧ (into_master ⇒ index' = scan(master))
PSEUDOCODE
validate the path and list its trashed versions
if listing, return them
choose the newest version, or the newest from the given day
destination: the master, or the export directory (created if needed)
refuse to overwrite; copy the version out
if into the master, rebuild the index
journal, log and report
LIMITATIONS
journal_readloads every monthly journal into memory before filtering and applyinglimit. A repo with years of history pays for all of it on everyfilesync-admin log.restoreinto the master rebuilds the index by walking the whole tree (reusing hashes whose size and mtime match), and does not take the repo lock: a session running at the same moment can save an index that omits the restored file until its next sync.Admin methods take no lock except
reindex;client_allowrewriting the repo config while the server reads it relies on the atomic rename insave_repo.
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.