NAME

App::FileSync::Client - sync one repo, and the client-side repo commands

SYNOPSIS

my $config = App::FileSync::Config::load_client();
my $client = App::FileSync::Client->new(config => $config, name => 'docs', opts => { verbose => 1 });

my $result = $client->sync;
exit $result->{exit};

DESCRIPTION

A sync is one session with the server: hello, manifest, plan, then the transfers the plan lists, then done. The client keeps under ~/.filesync (or FILESYNC_HOME):

cache/NAME.json          the base: what the last successful sync left
logs/NAME.jsonl          one line per attempt, for "filesync log"
locks/NAME.lock          held for the whole run
trash/repos/NAME/Y/M/D/  files the sync deleted or replaced locally

Exit codes: 0 ok, 1 error, 2 another run holds the lock, 3 finished with warnings (conflicts or skipped paths).

METHODS

new

Purpose: a client for one repo of a client configuration.

Args: config (from "load_client" in App::FileSync::Config), name (the repo's key in it), optional opts (dry_run, force, no_prune, no_delta, verbose, quiet), logger, settings (process settings, read lazily otherwise), language.

Returns: the client.

Side Effects: builds a logger from the settings when none is given.

Usage:

my $client = App::FileSync::Client->new(config => $config, name => 'docs');

EXAMPLE

my $client = App::FileSync::Client->new(
	config => $config, name => 'docs', opts => { dry_run => 1 },
);

API SPECIFICATION

INPUT

{
	config   => { type => 'hashref' },
	name     => { type => 'string', min => 1, max => 64 },
	opts     => { type => 'hashref', optional => 1 },
	logger   => { type => 'object', optional => 1 },
	settings => { type => 'hashref', optional => 1 },
	language => { type => 'string', optional => 1 },
}

OUTPUT

{ type => 'object', isa => 'App::FileSync::Client' }

MESSAGES

Key              Meaning                              Resolution
client.no_config no configuration was given           pass config
client.bad_name  the repo name is not a plain id      rename the repo in config.json

FORMAL SPECIFICATION

pre config ≠ ⊥ ∧ name ∈ Id
post self.name = name ∧ self.opts = opts ∨ ∅

sync

Purpose: run one sync session for the repo.

Args: none (see opts in "new").

Returns: { repo, ok, exit, summary, warnings, stats }; with dry_run, also dry_run => 1 and the planned actions. A lock refusal (here or on the server) returns ok => 0, exit => 2, locked => 1 rather than raising: cron starting a run while the last one is finishing is normal.

Side Effects: changes the local tree and the master; updates the cache; prunes this repo's local trash (unless no_prune); appends to the run log, whatever the outcome.

Usage:

my $result = App::FileSync::Client->new(%args)->sync;

EXAMPLE

my $result = $client->sync;
printf "pulled %d, pushed %d\n", @{ $result->{summary} }{qw(pulled pushed)};
print "warning: $_\n" for @{ $result->{warnings} };

API SPECIFICATION

INPUT

[ { name => 'self', type => 'object' } ]

OUTPUT

{
	type => 'hashref',
	schema => {
		repo     => { type => 'string' },
		ok       => { type => 'integer', memberof => [ 0, 1 ] },
		exit     => { type => 'integer', memberof => [ 0, 2, 3 ] },
		summary  => { type => 'hashref', optional => 1 },
		warnings => { type => 'arrayref', optional => 1 },
	},
}

MESSAGES

Key                  Meaning                                    Resolution
client.no_repo       the repo is not in the configuration       filesync init
client.bad_root      the sync root is not a directory           fix root in config.json
client.no_marker     the root has no .filesync-root marker      mount the drive, or create the marker
client.no_client_id  the configuration has no client_id         filesync init
client.refused       the server refused the session             read the reason
client.req.*         the server rejected a step                 read the reason
client.hash_mismatch a transfer arrived corrupted               rerun the sync

FORMAL SPECIFICATION

ΔFS
sync = ⟨exit ↦ 2⟩                              if lock held elsewhere
sync = ⟨exit ↦ 0 | 3⟩ ∧ local' ≡ master'       if the session completes
sync raises ∧ base' = base                     otherwise

PSEUDOCODE

look up the repo
run the session (lock, transport, protocol)
record the attempt in the run log, success or failure
log a summary; return the result, or re-raise the error

log

Purpose: recent run attempts from the local run log.

Args: optional limit (default 20), since and until (YYYY-MM-DD or epoch, inclusive), exits (an exit code or arrayref of them), warnings (only runs that recorded a warning, conflict or skipped path). All filters apply before the limit.

Returns: { repo, path, runs } (runs oldest first), plus the filters used.

Side Effects: reads the log. A torn final line from a killed run is skipped.

Usage:

my $log = $client->log(limit => 5, exits => [ 1, 2 ]);

EXAMPLE

for my $run (@{ $client->log(since => '2026-10-01')->{runs} }) {
	print "$run->{ts} exit $run->{exit}\n";
}

API SPECIFICATION

INPUT

{
	limit    => { type => 'integer', optional => 1 },
	since    => { type => 'string', optional => 1 },
	until    => { type => 'string', optional => 1 },
	exits    => { type => 'arrayref', optional => 1 },	# a lone code is wrapped
	warnings => { type => 'boolean', optional => 1 },
}

OUTPUT

{ type => 'hashref', schema => { repo => { type => 'string' }, path => { type => 'string' }, runs => { type => 'arrayref' } } }

MESSAGES

Key        Meaning                     Resolution
when.bad   since/until is malformed    use YYYY-MM-DD or an epoch
io.read    the log cannot be read      check permissions

FORMAL SPECIFICATION

log = last limit ⟨ r ∈ runs | since ≤ r.ts ≤ until ∧ (exits = ∅ ∨ r.exit ∈ exits) ∧ (¬warnings ∨ has_warnings(r)) ⟩

PSEUDOCODE

parse the filters
read each JSON line of the log, skipping torn ones
keep the runs that pass every filter
keep the newest limit; report the filters used

verify

Purpose: compare the local tree with the base the last successful sync recorded. Drift means the tree changed without being synced.

Args: optional deep (hash every file rather than trusting size and mtime).

Returns: the App::FileSync::Verify report, plus repo.

Side Effects: reads the tree; logs a summary. Works offline: the ignore list is the one cached by the last sync.

Usage:

my $report = $client->verify(deep => 1);

EXAMPLE

my $report = $client->verify;
print $report->{ok} ? "clean\n" : scalar(@{ $report->{modified} }) . " modified\n";

API SPECIFICATION

INPUT

{ deep => { type => 'boolean', optional => 1 } }

OUTPUT

{ type => 'hashref' }

MESSAGES

Key               Meaning                                Resolution
client.bad_root   the sync root is not a directory       fix root in config.json
client.no_marker  the root has no .filesync-root marker  mount the drive

FORMAL SPECIFICATION

verify = Verify(root, cache.entries, Ignore(cache.ignore ∪ {snapshots}))

restore

Purpose: put a path back from this repo's local trash. The newest stored version wins; list shows them all instead.

Args: path, optional to (a directory; default the sync root), list.

Versions are searched in this repo's trash first, then in the shared trash that releases before 0.001.1 used, so files trashed before an upgrade can still be restored.

Returns: with list, { repo, path, versions }; otherwise { repo, path, from, to }.

Side Effects: copies the version out; the trash entry is kept.

Usage:

$client->restore(path => 'notes/a.txt', to => '/tmp/out');

EXAMPLE

my $versions = $client->restore(path => 'a.txt', list => 1)->{versions};
print "$_->{date} $_->{size}\n" for @{$versions};

API SPECIFICATION

INPUT

{
	path => { type => 'string', min => 1 },
	to   => { type => 'string', optional => 1 },
	list => { type => 'boolean', optional => 1 },
}

OUTPUT

{ type => 'hashref' }

MESSAGES

Key                 Meaning                                 Resolution
client.unsafe       the path fails validate_relpath         fix the path
client.no_versions  nothing of that path is in the trash    check --list
client.not_dir      the target is not a directory           choose another
client.exists       the destination exists                  move it aside or use --to

FORMAL SPECIFICATION

ΔFS
pre path ∈ SafePath ∧ versions(path) ≠ ⟨⟩ ∧ dest ¬in; dom FS
post tree'(dest) = tree(head versions(path))

PSEUDOCODE

validate the path; list versions from the repo trash then the legacy trash
if listing, return them
destination := to (created) or the sync root, plus the path
refuse to overwrite; copy the newest version out

snapshot_create

Purpose: take today's snapshot of a replica.

Args: optional keep (how many snapshots to keep; default the repo's snapshot_keep, else 14; 0 keeps all).

Returns: { repo, ok, exit, keep, pruned, older, ... } with the "create" in App::FileSync::Snapshot result merged in; exit => 2 when another run holds the lock.

Side Effects: holds the repo lock; creates a snapshot; prunes old ones when a new one was made.

Usage:

my $made = $client->snapshot_create(keep => 7);

EXAMPLE

my $made = $client->snapshot_create;
print $made->{created} ? "snapshot $made->{date}\n" : "already have $made->{date}\n";

API SPECIFICATION

INPUT

{ keep => { type => 'integer', min => 0, optional => 1 } }

OUTPUT

{ type => 'hashref' }

MESSAGES

Key                 Meaning                           Resolution
client.not_replica  the repo is a client              snapshots are for replicas
snapshot.*          see App::FileSync::Snapshot

FORMAL SPECIFICATION

pre role = replica
created ∧ keep > 0 ⇒ #snapshots' ≤ keep

PSEUDOCODE

refuse unless the repo is a replica
take the lock, or report that another run holds it
choose the mode and the number to keep
create today's snapshot; prune if one was made

snapshot_list

Purpose: a replica's snapshots, newest first.

Args: none.

Returns: { repo, snapshots }.

Side Effects: walks every snapshot to count it.

Usage:

my $list = $client->snapshot_list;

EXAMPLE

print "$_->{date}\n" for @{ $client->snapshot_list->{snapshots} };

API SPECIFICATION

INPUT

[ { name => 'self', type => 'object' } ]

OUTPUT

{ type => 'hashref', schema => { repo => { type => 'string' }, snapshots => { type => 'arrayref' } } }

MESSAGES

Key              Meaning                            Resolution
client.no_repo   the repo is not in the config      filesync init

FORMAL SPECIFICATION

snapshot_list = ⟨repo ↦ name, snapshots ↦ Snapshot::list(root)⟩

snapshot_restore

Purpose: copy a snapshot (or one path of it) back.

Args: date, optional path (one relative path; default everything) and to (default the sync root, which rolls the replica back).

Returns: the "restore" in App::FileSync::Snapshot result plus repo, ok, exit and into_root; exit => 2 when another run holds the lock.

Side Effects: holds the repo lock; replaces files under to.

Usage:

$client->snapshot_restore(date => '2026-10-20', path => 'notes');

EXAMPLE

my $done = $client->snapshot_restore(date => '2026-10-20', to => '/tmp/export');
print "$done->{files} files\n";

API SPECIFICATION

INPUT

{
	date => { type => 'string', optional => 1 },
	path => { type => 'string', optional => 1 },
	to   => { type => 'string', optional => 1 },
}

OUTPUT

{ type => 'hashref' }

MESSAGES

See "restore" in App::FileSync::Snapshot.

FORMAL SPECIFICATION

ΔFS
into_root ⇔ to = root
tree'(to / p) = tree(snapshot(date) / p) for each restored p

cache_path

Documented with "log_path" below.

log_path

Purpose: where this repo's cache and run log live.

Args: none.

Returns: HOME/cache/NAME.json, HOME/logs/NAME.jsonl.

Side Effects: none.

Usage:

my $path = $client->cache_path;

EXAMPLE

print $client->log_path, "\n";

API SPECIFICATION

INPUT

[ { name => 'self', type => 'object' } ]

OUTPUT

{ type => 'string', min => 1 }

MESSAGES

Key              Meaning                        Resolution
config.no_home   no home directory is known     set HOME or FILESYNC_HOME

FORMAL SPECIFICATION

cache_path = home / "cache" / name ⁀ ".json"
log_path   = home / "logs" / name ⁀ ".jsonl"

cache

Purpose: the cached base and last-run summary.

Args: none.

Returns: the cache hashref, always with an entries hashref; an empty cache when there is none or it is unreadable (the next sync rebuilds it).

Side Effects: reads the cache file.

Usage:

my $last = $client->cache->{last_run};

EXAMPLE

my $cache = $client->cache;
printf "%d files at the last sync\n", scalar keys %{ $cache->{entries} };

API SPECIFICATION

INPUT

[ { name => 'self', type => 'object' } ]

OUTPUT

{ type => 'hashref', schema => { entries => { type => 'hashref' } } }

MESSAGES

None; a damaged cache is treated as empty.

FORMAL SPECIFICATION

cache = json(FS(cache_path)) if that parses to an object, else ⟨entries ↦ ∅⟩

LIMITATIONS

  • The base is a JSON file rewritten whole after every sync; a tree of millions of paths makes that write, and the manifest it is built from, large.

  • log reads the whole run log; it is trimmed to 500 lines, so this stays small.

  • Local trash written by releases before 0.001.1 sits in one shared directory. It is still searched by restore and pruned with the default 30 days, but a restore can then offer another repo's file of the same relative path.

  • A pull is verified against the hash in the plan, which the server computed when it planned. If the master changes during the session (only possible if something other than filesync writes to it), the pull fails with a hash mismatch and the next sync retries.

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.