diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 16:15:48 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 16:15:48 +0200 |
| commit | 24547e4efd3a45b36b3bea8c29cfc687230a6d3a (patch) | |
| tree | 05aed558b665841230b0a1b4d2f987091c573ab8 /packages/meshbay-hub | |
| parent | 1c97ffb91bb4928022e995709d7e55b19cfd18eb (diff) | |
| download | meshbay-24547e4efd3a45b36b3bea8c29cfc687230a6d3a.tar.gz | |
feat(android): send a manifest of what each backup run sent
Photos, videos and files record, per file, their path on the node, their path
and album on the phone, dates, size and SHA-256, uploaded as
meshbay-manifest/manifest-<date>.jsonl once the run is done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
4 files changed, 66 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js index 1439c20..a9d4517 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js @@ -172,8 +172,8 @@ export function createBackup({ if (refused === 'offline') { setState({ phase: 'offline' }); return; } if (refused) { await lasting(refused); return; } - const { items } = await sync.plan(); - if (!items.length) { + const { items, manifest } = await sync.plan(); + if (!items.length && !manifest) { await sync.completed(); setState({ phase: 'up_to_date', done: 0, total: 0 }); return; @@ -246,10 +246,29 @@ export function createBackup({ done += 1; await sync.keepAlive(true, report()); } + if (!await sendManifest(transport, dest, dirs)) return; await sync.completed(); setState({ phase: 'up_to_date', done, total: items.length }); } + // What the phone knew of what was sent (its path and album, its dates and + // hash), for a restore or a merge later: one file per run that sent + // anything, beside the files, kept by the phone until the node has it. + // False when a lasting refusal stopped it, which is then said. + async function sendManifest(transport, dest, dirs) { + const { item } = await sync.manifest(); + if (!item) return true; + try { + await makeDirs(transport, dest.folder, item.dir, dirs); + await upload(transport, item, sync.file); + } catch (e) { + if (e.code && failures[e.code]) { await lasting(e.code); return false; } + throw e; + } + await sync.manifestSent(item.token); + return true; + } + async function lasting(code) { setState({ phase: 'failed', reason: code }); await sync.failed(code, say(code)).catch(() => {}); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 04e26e6..b1a8ae9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -244,6 +244,8 @@ export const photoSync = { estimate(settings) { return bridge.photoSync.estimate(settings); }, plan() { return bridge.photoSync.plan(); }, sent(token, dir, name) { return bridge.photoSync.sent(token, dir, name); }, + manifest() { return bridge.photoSync.manifest(); }, + manifestSent(token) { return bridge.photoSync.manifestSent(token); }, completed() { return bridge.photoSync.completed(); }, failed(code, text) { return bridge.photoSync.failed(code, text); }, keepAlive(on, text) { return bridge.photoSync.keepAlive(on, text); }, @@ -285,6 +287,8 @@ export const driveSync = { configure(settings) { return driveBridge.configure(settings); }, plan() { return driveBridge.plan(); }, sent(token, dir, name) { return driveBridge.sent(token, dir, name); }, + manifest() { return driveBridge.manifest(); }, + manifestSent(token) { return driveBridge.manifestSent(token); }, completed() { return driveBridge.completed(); }, failed(code, text) { return driveBridge.failed(code, text); }, keepAlive(on, text) { return driveBridge.keepAlive(on, text); }, diff --git a/packages/meshbay-hub/tests/test_drive_sync.py b/packages/meshbay-hub/tests/test_drive_sync.py index d46ac66..7fc35d2 100644 --- a/packages/meshbay-hub/tests/test_drive_sync.py +++ b/packages/meshbay-hub/tests/test_drive_sync.py @@ -75,7 +75,9 @@ fake.transport = { const hubFetch = async () => ({ admin_id: 'u-bob', members: fake.members, invited: [] }); win.meshbay = { driveSync: { status: async () => fake.status, - plan: async () => { fake.log.push(['plan']); return { items: fake.items }; }, + plan: async () => { fake.log.push(['plan']); return { items: fake.items, manifest: false }; }, + manifest: async () => ({ item: null }), + manifestSent: async () => true, sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; }, completed: async () => { fake.log.push(['completed']); fake.status.lastCompleted = Date.now(); return fake.status; }, failed: async (code, text) => { fake.log.push(['failed', code, text]); return true; }, diff --git a/packages/meshbay-hub/tests/test_photo_sync.py b/packages/meshbay-hub/tests/test_photo_sync.py index c422f22..266a111 100644 --- a/packages/meshbay-hub/tests/test_photo_sync.py +++ b/packages/meshbay-hub/tests/test_photo_sync.py @@ -112,7 +112,9 @@ fake.transport = { }; win.meshbay = { photoSync: { status: async () => fake.status, - plan: async () => { fake.log.push(['plan']); return { items: fake.items }; }, + plan: async () => { fake.log.push(['plan']); return { items: fake.items, manifest: !!fake.manifest }; }, + manifest: async () => ({ item: fake.manifest || null }), + manifestSent: async (token) => { fake.log.push(['manifest-sent', token]); fake.manifest = null; return true; }, sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; }, completed: async () => { fake.log.push(['completed']); @@ -310,6 +312,41 @@ def test_another_account_on_the_phone_sends_nothing(tmp_path): assert ["plan"] not in log +# ── the manifest ──────────────────────────────────────────────────────────── + +MANIFEST = """ +fake.manifest = { token: 'mf', name: 'manifest-2026-10-10-090000.jsonl', + dir: 'Media/Photos/Bob/bob-photos/meshbay-manifest', size: 7 }; +""" + + +def test_a_run_ends_with_the_manifest_of_what_it_sent(tmp_path): + log = _run(_start(MANIFEST), tmp_path) + uploads = _of(log, "upload") + assert uploads[-1][:2] == ["Media/Photos/Bob/bob-photos/meshbay-manifest", + "manifest-2026-10-10-090000.jsonl"] + assert ["mkdir", "Media/Photos/Bob/bob-photos/meshbay-manifest"] in log + assert log.index(["manifest-sent", "mf"]) < log.index(["completed"]) + + +def test_a_manifest_left_by_an_interrupted_run_goes_on_its_own(tmp_path): + log = _run(_start(MANIFEST + "fake.items = [];"), tmp_path) + assert [u[1] for u in _of(log, "upload")] == ["manifest-2026-10-10-090000.jsonl"] + assert ["manifest-sent", "mf"] in log and ["completed"] in log + + +def test_a_manifest_the_node_refused_is_kept_for_the_next_run(tmp_path): + log = _run(_start(MANIFEST + """ +const real = fake.transport.uploadFile; +fake.transport.uploadFile = async (file, opts) => { + if (file.name.startsWith('manifest-')) { const e = new Error('full'); e.code = 'disk_full'; throw e; } + return real(file, opts); +};"""), tmp_path) + assert _of(log, "manifest-sent") == [] + assert _of(log, "failed") == [["disk_full"]] + assert ["completed"] not in log + + # ── the account's own group, at every run ─────────────────────────────────── @pytest.mark.parametrize("setup", [ |