Files
NetMesh/infrastructure/services/syncAnchorDecision.js

74 lines
3.3 KiB
JavaScript
Raw Normal View History

/**
* syncAnchorDecision pure "has the remote changed since we last saw it?"
* logic extracted from CloudSyncManager so it can be exercised by
* `node --test` without standing up the full manager harness.
*
* Called from CloudSyncManager.inspectProviderRemoteState after the
* remote has been downloaded and its signature computed. Given the
* previous anchor and the current state, decides whether the remote
* looks different enough to warrant re-merging.
*
* Four decisions matter for data integrity:
*
* 1. Anchor missing + remote empty not changed (first sync, nothing
* to merge from). Callers MUST still guard against pushing an empty
* local vault (see useAutoSync `hasMeaningfulSyncData`) that guard
* is orthogonal to this decision.
* 2. Anchor missing + remote non-empty changed (first sync, remote
* has data we've never observed three-way merge with empty base).
* 3. Anchor present + resourceId drift changed (provider created a
* fresh file; reuse of the old anchor would be meaningless).
* 4. Anchor present + signature mismatch changed (same resource, new
* ciphertext standard drift).
*
* Any other state is "unchanged", and callers short-circuit the merge.
*
* @param {{
* currentSignature: string | null,
* currentResourceId: string | null,
* anchor: { signature?: string | null, resourceId?: string | null } | null,
* hasRemoteFile: boolean,
* }} input
* @returns {{ remoteChanged: boolean, reason: string }}
*/
export function decideRemoteChanged(input) {
const { currentSignature, currentResourceId, anchor, hasRemoteFile } = input;
if (!anchor) {
// No anchor means we've never observed this provider.
if (!hasRemoteFile) {
// Remote has no file at all → nothing to merge.
return { remoteChanged: false, reason: 'no-anchor-no-remote' };
}
if (currentSignature === null) {
// hasRemoteFile=true but the signature computed to null — the
// file exists but we can't hash its meta (malformed shape, newer
// schema, partial download). Treat as CHANGED so the caller
// routes through the three-way merge / decrypt path rather than
// silently short-circuiting and letting the next upload overwrite
// an unreadable-but-extant remote file. If the payload is
// decryptable the merge will succeed; if it isn't, the decrypt
// error surfaces to the user, which is strictly safer than a
// silent stomp.
return { remoteChanged: true, reason: 'unreadable-remote' };
}
return { remoteChanged: true, reason: 'no-anchor-remote-has-data' };
}
// Resource identity drift: provider returned a different resource
// (e.g. a freshly-created gist, or the user reconnected and the
// adapter picked a new file). The previous anchor's signature is
// meaningless once the resource id changes.
const anchorResourceId = anchor.resourceId ?? null;
if (anchorResourceId !== currentResourceId) {
return { remoteChanged: true, reason: 'resource-id-changed' };
}
// Same resource, different signature → new ciphertext/meta.
if ((anchor.signature ?? null) !== currentSignature) {
return { remoteChanged: true, reason: 'signature-mismatch' };
}
return { remoteChanged: false, reason: 'anchor-matches' };
}