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 copy if anything but filesync writes there.

  • list walks 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.