fix(privacy): close the gaps three review passes found in block A

The worst of them made the block's own privacy promise backwards.

PrivacyPersistChannels was given a non-empty field initializer so a
fresh config would record conversations only. Dalamud deserialises with
Json.NET's defaults, which means ObjectCreationHandling.Auto: a
collection field that already holds items gets *populated*, not
replaced. Verified against Newtonsoft 13.0.3 -- saved [] loads as the
initializer, saved [Say] loads as initializer plus Say. So the change
would have unioned the privacy-first list into every existing config on
load and switched channels back on that the user had unticked, while
also making the v24 migration unreachable and its self-test vacuous. The
field is empty again and the seeding moved to CreateFresh, which only
runs when there is no config file at all.

Cleanup could delete a channel it had promised to keep. The allowlist
could only name channels that were already in the database when the
preview ran, so an unrecognised channel whose first message arrived
afterwards fell outside it. Where the failsafe is on, the deletion now
names what goes -- known channels that are not on the list -- instead of
what stays. The window closes completely, and a listed channel that
happens to be empty right now is safe for the same reason.

The cleanup preview was the one long operation that never took the
shared lock, while holding an open reader across a full-table scan.
That is precisely the case the lock was written for.

Clearing the history reported success when it failed. ClearMessages
purges the full-text index between the delete and the VACUUM; if that
step throws, the plaintext stays on disk and the user was told it was
gone. It has its own error string now, in all 25 languages.

Also:

- One busy state for the whole tab. Cleanup, clear, maintenance and
  export reach the same store, and per-section flags left two
  destructive buttons live at once. The lock turned that into a refusal
  rather than damage, but a refusal you have to trigger to discover is
  not an answer.
- The gate carries a revision, bumped by every mutating operation that
  finishes. A preview taken before a retention sweep no longer passes as
  current afterwards: comparing it against the settings alone cannot see
  that the rows it counted are gone.
- Database metadata moved to a worker. Checking "is anything busy" first
  is not enough, because an operation can take the lock in the gap
  before COUNT(*) runs, and then the game stands still for a whole file
  rewrite.
- The clear hint stays hidden until the count has actually been read.
  "0 messages are stored" in front of the clear button is a lie told at
  the worst possible moment.
- Refusal notices read the operation once. Guard and name were two reads
  of the same field, so a run finishing in between printed a sentence
  that stopped at the colon.
- The retention sweep cannot start twice. The gate only goes busy once
  the worker reaches TryBegin, and the due-check runs every tick.
- Teardown waits up to five seconds for the store to come free rather
  than disposing the connection under a running VACUUM.
- Maintenance has its own flag and says so when it is refused; reload
  gets the same guard as its neighbour; the breakdown tree keeps its
  open state across a language switch.
This commit is contained in:
2026-08-18 22:13:33 +02:00
parent 1987d745d8
commit 0279a1a9d6
31 changed files with 431 additions and 123 deletions
+35
View File
@@ -635,6 +635,41 @@ internal class MessageStore : IDisposable
}
}
// Hard-deletes every message whose ChatType IS in the list, then VACUUMs.
// Returns the number of rows deleted.
//
// The mirror image of CleanupRetainOnly, and the privacy filter needs both.
// With the unknown-channel failsafe on, the rule keeps every channel this
// build does not recognise -- and a retain-list can only name the ones that
// were already in the database when the list was built, so a channel whose
// first message arrives after that would be deleted. Naming what goes
// instead of what stays removes the window entirely.
internal long CleanupDeleteTypes(IReadOnlyCollection<int> deleteTypes)
{
if (deleteTypes.Count == 0)
return 0;
lock (_readLock)
{
long deleted;
using (var cmd = Connection.CreateCommand())
{
var placeholders = BindIntList(cmd, "dt", deleteTypes);
cmd.CommandText = $"DELETE FROM messages WHERE ChatType IN ({placeholders});";
cmd.CommandTimeout = 600;
deleted = cmd.ExecuteNonQuery();
}
if (deleted > 0)
{
InvalidateFtsIndex();
PerformMaintenance();
}
return deleted;
}
}
internal void PerformMaintenance()
{
lock (_readLock)