NAME

App::FileSync::Store - the server-side files of one repo

SYNOPSIS

my $store = App::FileSync::Store->new(root => '/srv/filesync', repo => 'docs');
my $lock  = $store->acquire_lock() or die 'busy';
my $index = $store->load_index();
my $scan  = $store->walk_master($index);
$store->save_index($scan->{manifest});

METHODS

new

Purpose: open a repo on the server.

Args: root, repo, optional config (the repo config, read from disk when omitted) and language.

Returns: the store.

Side Effects: reads the repo config.

Usage:

my $store = App::FileSync::Store->new(root => $root, repo => $repo);

EXAMPLE

my $store = App::FileSync::Store->new(root => '/srv/filesync', repo => 'docs');
print $store->master_dir, "\n";

API SPECIFICATION

INPUT

{
	root     => { type => 'string', min => 1 },
	repo     => { type => 'string', min => 1 },
	config   => { type => 'hashref', optional => 1 },
	language => { type => 'string', optional => 1 },
}

OUTPUT

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

MESSAGES

Key              Meaning                       Resolution
args.invalid     root or repo missing          pass both
store.bad_repo   the repo name is not an id    use letters, digits, '.', '_', '-'
config.no_repo   the repo does not exist       filesync-admin repo create

FORMAL SPECIFICATION

new(r, n, c) = ⟨root ↦ r, repo ↦ n, config ↦ c or load_repo(r, n), ignore ↦ Ignore(config.ignore)⟩

root

Documented with "journal_path" below.

repo

Documented with "journal_path" below.

config

Documented with "journal_path" below.

master_dir

Documented with "journal_path" below.

meta_dir

Documented with "journal_path" below.

index_path

Documented with "journal_path" below.

trash_dir

Documented with "journal_path" below.

lock_path

Documented with "journal_path" below.

journal_path

Purpose: where the repo's pieces live.

Args: none.

Returns: the server root, the repo name, the config hashref, and the paths of the master tree (ROOT/master/REPO), the metadata directory, the index (master.json), the trash, the lock file and this month's journal (journal/YYYY-MM.jsonl, UTC).

Side Effects: none.

Usage:

my $master = $store->master_dir;

EXAMPLE

print join("\n", $store->master_dir, $store->index_path, $store->journal_path), "\n";

API SPECIFICATION

INPUT

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

OUTPUT

{ type => 'string' }

MESSAGES

None.

FORMAL SPECIFICATION

master_dir = root / "master" / repo
meta_dir = root / ".filesync" / "repos" / repo
index_path = meta_dir / "master.json"
journal_path = meta_dir / "journal" / yyyy-mm(now) ⁀ ".jsonl"

base_path

Purpose: where a client's base (its state at its last sync) is stored.

Args: $client, a client id.

Returns: META/clients/CLIENT.json.

Side Effects: none.

Usage:

my $path = $store->base_path('laptop-1');

EXAMPLE

unlink $store->base_path($client) if $forget;

API SPECIFICATION

INPUT

[
	{ name => 'self',   type => 'object' },
	{ name => 'client', type => 'string', min => 1 },
]

OUTPUT

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

MESSAGES

Key              Meaning                              Resolution
store.bad_client the id is not a valid client id      use letters, digits, . _ -

FORMAL SPECIFICATION

pre client ∈ Id
base_path(client) = meta_dir / "clients" / client ⁀ ".json"

acquire_lock

Purpose: take the repo's session lock without waiting.

Args: none.

Returns: the lock handle, or undef when another session holds it.

Side Effects: creates the lock file.

Usage:

my $lock = $store->acquire_lock() or return refuse('locked');

EXAMPLE

if (my $lock = $store->acquire_lock) { ... }

API SPECIFICATION

INPUT

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

OUTPUT

{ type => 'handle', optional => 1 }

MESSAGES

Key         Meaning                         Resolution
io.append   the lock file cannot be made    check permissions

FORMAL SPECIFICATION

acquire_lock = Lock::acquire(lock_path, nonblock)

abs_path

Purpose: the absolute path in the master for a relative path.

Args: $rel.

Returns: the path under "master_dir".

Side Effects: none. Raises if $rel is unsafe or if a directory on the way is a symlink, so nothing can be written outside the master through one.

Usage:

my $abs = $store->abs_path($path);

EXAMPLE

my $abs = $store->abs_path('notes/plan.txt');

API SPECIFICATION

INPUT

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

OUTPUT

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

MESSAGES

Key              Meaning                                  Resolution
path.unsafe      the path fails validate_relpath          rename the file
store.symlink    a parent directory is a symlink          remove the symlink

FORMAL SPECIFICATION

pre rel ∈ SafePath ∧ ∀ a : ancestors(master / rel) ∩ under(master) • kind a ≠ symlink
abs_path(rel) = master / rel

load_index

Documented with "save_base" below.

save_index

Documented with "save_base" below.

load_base

Documented with "save_base" below.

save_base

Purpose: read and write the master's index and a client's base.

Args: save_index(\%entries); load_base($client); save_base($client, \%entries).

Returns: the entries hashref (empty when the file does not exist); 1 for the save methods.

Side Effects: the save methods replace the file atomically.

Usage:

my $index = $store->load_index();
$store->save_base($client, $base_after);

EXAMPLE

my $base = $store->load_base('laptop-1');
print scalar(keys %{$base}), " paths known to laptop-1\n";

API SPECIFICATION

INPUT

[
	{ name => 'self',    type => 'object' },
	{ name => 'client',  type => 'string', optional => 1 },
	{ name => 'entries', type => 'hashref', optional => 1 },
]

OUTPUT

{ type => 'hashref' }

MESSAGES

Key            Meaning                       Resolution
config.parse   the file is not valid JSON    run filesync-admin reindex
io.write       the file cannot be written    check space

FORMAL SPECIFICATION

load(p) = json(FS(p)).entries if p ∈ dom FS ∧ is a hash, else ∅
save(p, e): FS'(p) = json(⟨version ↦ 1, entries ↦ e⟩) ⁀ "\n"

walk_master

Purpose: scan the master, reusing indexed hashes where size and mtime match.

Args: optional \%cache (usually the current index; {} forces a rehash).

Returns: the "scan" in App::FileSync::Scan result: { manifest, stats, skipped }.

Side Effects: reads the master tree.

Usage:

my $scan = $store->walk_master($store->load_index);

EXAMPLE

my $fresh = $store->walk_master({});
printf "%d files\n", $fresh->{stats}{files};

API SPECIFICATION

INPUT

[
	{ name => 'self',  type => 'object' },
	{ name => 'cache', type => 'hashref', optional => 1 },
]

OUTPUT

{ type => 'hashref' }

MESSAGES

Key               Meaning                     Resolution
store.no_master   the master directory is gone   restore it or recreate the repo

FORMAL SPECIFICATION

pre isdir(master_dir)
walk_master(c) = scan(master_dir, ignore, c)

ensure_parent

Purpose: create the directory a master path will be written into.

Args: $rel.

Returns: the parent directory.

Side Effects: creates missing directories.

Usage:

$store->ensure_parent($path);

EXAMPLE

$store->ensure_parent('new/deep/dir/file.txt');

API SPECIFICATION

INPUT

[
	{ name => 'self', type => 'object' },
	{ name => 'rel',  type => 'string', min => 1 },
]

OUTPUT

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

MESSAGES

As for "abs_path", plus io.mkdir.

FORMAL SPECIFICATION

isdir'(dirname(abs_path(rel)))

temp_file

Purpose: an exclusive temporary file beside a master path.

Args: $rel, the path the temporary file will become.

Returns: [ $filehandle, $path ].

Side Effects: creates the file (and its directory).

Usage:

my ($fh, $temp) = @{ $store->temp_file($path) };

EXAMPLE

my ($fh, $temp) = @{ $store->temp_file('notes/plan.txt') };
print {$fh} $content;
close $fh;
$store->commit_temp('notes/plan.txt', $temp, time, oct(644));

API SPECIFICATION

INPUT

[
	{ name => 'self', type => 'object' },
	{ name => 'rel',  type => 'string', min => 1 },
]

OUTPUT

{ type => 'arrayref', min => 2, max => 2 }

MESSAGES

As for "abs_path", plus io.temp.

FORMAL SPECIFICATION

temp_file(rel) = App::FileSync::IO::temp_file(dirname(abs_path(rel)))

commit_temp

Purpose: move a verified temporary file into the master.

Args: $rel, $temp, optional $mtime and $mode.

Returns: the absolute path written.

Side Effects: see "install_file" in App::FileSync::IO.

Usage:

$store->commit_temp($path, $temp, $mtime, $mode);

EXAMPLE

$store->commit_temp('a.txt', $temp, 1_700_000_000, oct(600));

API SPECIFICATION

INPUT

[
	{ name => 'self',  type => 'object' },
	{ name => 'rel',   type => 'string', min => 1 },
	{ name => 'temp',  type => 'string', min => 1 },
	{ name => 'mtime', type => 'number', optional => 1 },
	{ name => 'mode',  type => 'integer', optional => 1 },
]

OUTPUT

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

MESSAGES

As for "install_file" in App::FileSync::IO and "abs_path".

FORMAL SPECIFICATION

commit_temp(rel, t, m, mode) = install_file(t, abs_path(rel), m, mode)

move_to_trash

Purpose: move a master path into the repo's trash.

Args: $rel, optional $epoch.

Returns: where it went, or undef if it was not there.

Side Effects: see "move_to_trash" in App::FileSync::Trash.

Usage:

my $trashed = $store->move_to_trash($path, time);

EXAMPLE

my $where = $store->move_to_trash('old/report.txt');

API SPECIFICATION

INPUT

[
	{ name => 'self',  type => 'object' },
	{ name => 'rel',   type => 'string', min => 1 },
	{ name => 'epoch', type => 'number', optional => 1 },
]

OUTPUT

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

MESSAGES

As for "move_to_trash" in App::FileSync::Trash and "abs_path".

FORMAL SPECIFICATION

abs_path(rel) ¬in; dom FS ⇒ result = ⊥
abs_path(rel) ∈ dom FS ⇒ result = Trash::move_to_trash(abs_path(rel), rel, trash_dir, epoch)

clean_stale_temps

Documented with "prune_trash" below.

prune_trash

Purpose: housekeeping at the start of a session.

Args: clean_stale_temps(max_age => SECONDS); prune_trash(days => N, dry_run => BOOL). Defaults: one hour, and the repo's trash_days (30).

Returns: the number of temporaries removed; the arrayref of trash buckets removed.

Side Effects: deletes stale temporaries from the master and expired trash.

Usage:

$store->clean_stale_temps();
$store->prune_trash();

EXAMPLE

my $pruned = $store->prune_trash(dry_run => 1);

API SPECIFICATION

INPUT

{
	max_age => { type => 'integer', min => 0, optional => 1 },
	days    => { type => 'number', optional => 1 },
	dry_run => { type => 'boolean', optional => 1 },
}

OUTPUT

{ type => 'scalar' }

MESSAGES

None.

FORMAL SPECIFICATION

clean_stale_temps = Scan::clean_stale_temps(master_dir, age)
prune_trash = Trash::prune(trash_dir, days)

journal

Purpose: append an event to this month's journal.

Args: \%event; a ts (now) is added.

Returns: 1.

Side Effects: appends one JSON line, creating the journal directory.

Usage:

$store->journal({ client => $id, action => 'push', path => $path, result => 'ok' });

EXAMPLE

$store->journal({ action => 'reindex', files => 10, result => 'ok' });

API SPECIFICATION

INPUT

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

OUTPUT

{ type => 'integer', min => 1, max => 1 }

MESSAGES

Key         Meaning                        Resolution
io.append   the journal cannot be written  check space

FORMAL SPECIFICATION

FS'(journal_path) = FS(journal_path) ⁀ json({ts ↦ now} ⊕ event) ⁀ "\n"

LIMITATIONS

  • The index and every base are rewritten whole at the end of each session, so a session costs O(files) writes even when one file changed.

  • The symlink check walks the existing directories of each path on every write; a symlink created by an administrator between the check and the write is not caught.

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.