NAME
App::FileSync::Snapshot - daily snapshots of a replica
SYNOPSIS
my $made = App::FileSync::Snapshot::create(root => $replica_root);
my $all = App::FileSync::Snapshot::list(root => $replica_root);
App::FileSync::Snapshot::prune(root => $replica_root, keep => 14);
App::FileSync::Snapshot::restore(root => $replica_root, date => '2026-10-22', to => '/tmp/out');
FUNCTIONS
snapshot_root
Purpose: where a replica keeps its snapshots.
Args: $root, the replica's sync root.
Returns: ROOT/.filesync-snapshots.
Side Effects: none.
Usage:
my $dir = App::FileSync::Snapshot::snapshot_root($root);
EXAMPLE
print App::FileSync::Snapshot::snapshot_root('/data/replica'), "\n";
API SPECIFICATION
INPUT
[ { name => 'root', type => 'string', min => 1 } ]
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
Key Meaning Resolution
snapshot.no_root no root given pass the replica root
FORMAL SPECIFICATION
snapshot_root(r) = r / ".filesync-snapshots"
date_for_epoch
Documented with "default_mode" below.
is_date
Documented with "default_mode" below.
default_mode
Purpose: snapshot naming and the platform's preferred storage.
Args: date_for_epoch($epoch) (default now); is_date($string).
Returns: YYYY-MM-DD (UTC); 1 or 0; hardlink, or copy on Windows.
Side Effects: none.
Usage:
my $today = App::FileSync::Snapshot::date_for_epoch();
EXAMPLE
print App::FileSync::Snapshot::is_date('2026-10-22'), "\n"; # 1
API SPECIFICATION
INPUT
[ { name => 'value', type => 'scalar', optional => 1 } ]
OUTPUT
{ type => 'scalar' }
MESSAGES
None.
FORMAL SPECIFICATION
date_for_epoch(e) = yyyy-mm-dd of gmtime(e)
is_date(s) ⇔ s ∈ DIGIT⁴ ⁀ "-" ⁀ DIGIT² ⁀ "-" ⁀ DIGIT²
default_mode = copy if OS = MSWin32, hardlink otherwise
create
Purpose: take today's snapshot of the replica tree.
Args: root (required), optional date (default today, from now), now, mode (hardlink or copy; default "default_mode").
A day is never overwritten: the first snapshot of the day is the state before that day's first sync, which is the one worth keeping.
Returns: { date, path, created, files, bytes, linked, copied, mode }; created is 0 (and the counts 0) when that day already exists.
Side Effects: builds the snapshot in a temporary directory and renames it into place, so an interrupted run leaves no half snapshot. Removes interrupted attempts older than an hour. Hard links share storage with the live tree; a file that cannot be linked (another filesystem) is copied.
Usage:
my $made = App::FileSync::Snapshot::create(root => $root, mode => 'hardlink');
EXAMPLE
my $made = App::FileSync::Snapshot::create(root => '/data/replica');
print "$made->{files} files in $made->{path}\n" if $made->{created};
API SPECIFICATION
INPUT
{
root => { type => 'string', min => 1 },
date => { type => 'string', optional => 1, matches => qr/\A\d{4}-\d{2}-\d{2}\z/ },
now => { type => 'number', optional => 1 },
mode => { type => 'string', optional => 1, memberof => [ 'hardlink', 'copy' ] },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
Key Meaning Resolution
snapshot.not_dir the root is not a directory check the replica root
snapshot.bad_date the date is not YYYY-MM-DD fix the argument
snapshot.bad_mode the mode is not hardlink or copy fix snapshot_mode
snapshot.read a directory cannot be read check permissions
snapshot.failed a link, copy or rename failed check permissions and space
FORMAL SPECIFICATION
ΔFS
let t = snapshot_root / date
t ∈ dom FS ⇒ FS' = FS ∧ created = 0
t ¬in; dom FS ⇒ tree'(t) = tree(root) \ reserved ∧ created = 1
PSEUDOCODE
if today's directory exists: report it and stop
remove interrupted attempts older than an hour
mirror the tree into a fresh temporary directory, linking or copying
rename the temporary directory to the date
on any failure remove the temporary directory and raise
list
Purpose: every snapshot, newest first.
Args: root.
Returns: an arrayref of { date, path, files, bytes }.
Side Effects: walks every snapshot to count it.
Usage:
for my $snap (@{ App::FileSync::Snapshot::list(root => $root) }) { ... }
EXAMPLE
printf "%s %d files\n", $_->{date}, $_->{files}
for @{ App::FileSync::Snapshot::list(root => '/data/replica') };
API SPECIFICATION
INPUT
{ root => { type => 'string', min => 1 } }
OUTPUT
{ type => 'arrayref' }
MESSAGES
Key Meaning Resolution
snapshot.read the snapshot directory is unreadable check permissions
FORMAL SPECIFICATION
list(r) = sort_desc(date) ⟨ (d, r/S/d, #files(r/S/d), Σ size) | d ∈ children(r/S) ∩ DATE ⟩
prune
Purpose: keep the newest snapshots and remove the rest.
Args: root, keep (0 or undef keeps everything), dry_run.
Returns: { removed => [dates], kept => [dates] }.
Side Effects: removes old snapshot directories unless dry_run.
Usage:
App::FileSync::Snapshot::prune(root => $root, keep => 14);
EXAMPLE
my $plan = App::FileSync::Snapshot::prune(root => $root, keep => 7, dry_run => 1);
print "would remove @{ $plan->{removed} }\n";
API SPECIFICATION
INPUT
{
root => { type => 'string', min => 1 },
keep => { type => 'integer', min => 0, optional => 1 },
dry_run => { type => 'boolean', optional => 1 },
}
OUTPUT
{
type => 'hashref',
schema => { removed => { type => 'arrayref' }, kept => { type => 'arrayref' } },
}
MESSAGES
Key Meaning Resolution
snapshot.remove a snapshot cannot be removed check permissions
FORMAL SPECIFICATION
let s = list(root) (newest first)
kept = s[1 .. keep] ; removed = s[keep + 1 .. #s] if keep > 0 ∧ #s > keep
kept = s ; removed = ⟨⟩ otherwise
restore
Purpose: copy a snapshot (or part of one) out.
Args: root, date, to (the destination directory; created if missing), optional paths (relative paths inside the snapshot; default the whole tree), dry_run.
Existing destinations are replaced: this is a rollback, not a merge.
Returns: { date, from, to, files, bytes, paths }.
Side Effects: writes under to unless dry_run.
Usage:
App::FileSync::Snapshot::restore(root => $root, date => $date, to => $root, paths => [ 'notes' ]);
EXAMPLE
my $done = App::FileSync::Snapshot::restore(
root => '/data/replica', date => '2026-10-22', to => '/tmp/export',
);
print "$done->{files} files restored\n";
API SPECIFICATION
INPUT
{
root => { type => 'string', min => 1 },
date => { type => 'string', optional => 1 },
to => { type => 'string', optional => 1 },
paths => { type => 'arrayref', optional => 1 },
dry_run => { type => 'boolean', optional => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
Key Meaning Resolution
snapshot.need_date no date was given pass --date
snapshot.bad_date the date is not YYYY-MM-DD fix it
snapshot.none no snapshot for that date run "filesync snapshot list"
snapshot.need_to no destination was given pass --to
snapshot.to_not_dir the destination is not a directory choose another
snapshot.unsafe a path fails validate_relpath fix the path
snapshot.no_path the snapshot does not hold that path check the spelling
snapshot.not_regular a special file is in the snapshot restore by hand
snapshot.cannot_stat a snapshot entry vanished retry
snapshot.replace the destination cannot be replaced check permissions
FORMAL SPECIFICATION
ΔFS
pre date ∈ dom snapshots ∧ ∀ p : paths • p ∈ SafePath ∧ p ∈ dom tree(snapshot)
¬dry_run ⇒ ∀ p : paths • tree'(to / p) = tree(snapshot / p)
PSEUDOCODE
check the date and that the snapshot exists
create the destination
paths := given paths, or every top-level entry of the snapshot
for each path: validate it, then copy it out recursively, replacing what is there
LIMITATIONS
A hard-linked snapshot shares the file with the live tree. The sync replaces files by rename, which leaves the snapshot's link intact, but any other tool that edits a replica file in place also changes every snapshot that links it. Use
snapshot_mode copyif anything but filesync writes there.listwalks every snapshot to count it, so it is slow with many large snapshots.Snapshots live inside the replica root, on the same disk: they guard against bad syncs, not against losing that disk.
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.