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_read loads every monthly journal into memory before filtering and applying limit. A repo with years of history pays for all of it on every filesync-admin log.

  • restore into 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_allow rewriting the repo config while the server reads it relies on the atomic rename in save_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.