You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: earnings readings record which machine took them (#255)
* feat: earnings readings record which machine took them
The server half of CashPilot-Desktop-xjr, and the piece that gates the Desktop
side. The goal is the user's: pairing uploads a Desktop's history retroactively,
the Desktop then shows the complete picture, and on unlink it falls back to what
that machine earned alone.
The obvious implementation corrupts the data, and not visibly. The server does
not store EARNINGS -- it stores cumulative BALANCE READINGS and derives earnings
as clamped deltas between CONSECUTIVE readings. Two samplers of one provider
account, differenced as a single series, produce deltas between readings that
came from different samplers: the sequence oscillates, every downward step
clamps to zero, and the total is SYSTEMATICALLY UNDERSTATED while looking
entirely plausible. Worse than double counting, which at least overstates
visibly.
So a reading now records its `source` -- 'server' for this server's own
collectors, a client_id for a paired machine -- and deltas are taken per
(platform, source) and only then summed. Both delta readers were updated; the
second one was easy to miss.
That also makes (platform, source, date) the idempotency key, replacing
(platform, date): re-pairing or a retried import overwrites a day rather than
appending a second reading for it. Two machines reporting one platform on one
day is now the normal case rather than a constraint violation.
THE CURRENT-BALANCE QUERY DELIBERATELY STAYS ONE ROW PER PLATFORM. A provider
reports a single balance for the whole account, so the newest reading from ANY
source IS the current balance; taking the latest per source and summing would
multiply it by the number of machines watching.
I BROKE THE UPGRADE PATH TWICE, both times by declaring the new index in
_SCHEMA. _SCHEMA is replayed on EVERY startup and `CREATE TABLE IF NOT EXISTS`
is a no-op on an existing volume, so the index named `source` before the ALTER
added it and init_db died with "no such column: source" -- the app does not
start at all. The index is created in the MIGRATION, after the column, and a
comment says why. The pre-existing fx-migration test caught it both times.
The dedupe helper runs BEFORE the column is added, so its key now adapts to the
schema actually on disk; referencing `source` unconditionally crashed init_db on
exactly the volume that helper exists to rescue.
FOUR NEGATIVE CONTROLS FIRE, and one initially did not: keying deltas by
platform alone passed against test data where both sources reported the SAME
numbers, because the ORDER BY groups them and the single crossover clamps to
zero. The fixture now uses far-apart scales (10->12 and 50->52) so the crossover
is POSITIVE and would be counted as earnings that never happened.
* fix: the legacy index survived, and nothing could write a source
Three CodeRabbit findings on #255, all real, and the first is the one that
mattered.
THE LEGACY INDEX SURVIVED AN UPGRADE. I created idx_earnings_platform_source_date
but never dropped idx_earnings_platform_date, so on every existing volume the
OLD unique index kept forbidding two sources for one (platform, date) --
silently defeating the entire change for exactly the installs it was written
for. Creating a replacement is not removing a constraint.
My own upgrade test could not see it: the legacy table it builds had NO indexes,
so there was nothing to survive. It now builds the index too, and a second test
writes two sources for one day end to end. A control that skips the DROP fails
both.
NOTHING COULD WRITE A SOURCE. upsert_earnings had no `source` parameter, so
SQLite applied the default to every call and a paired client could never create
its own series through the storage API. The schema accepted a column that the
write path could not populate -- a feature that exists only in the table
definition. It takes `source: str = "server"` now, with tests that two sources
upsert independently and that one source still dedupes.
A TEST THAT DID NOT TEST WHAT ITS NAME CLAIMED. "a legacy row with no source"
set source='server' explicitly, so it exercised no fallback and merely repeated
the single-source case. The column is omitted now, which is the shape a migrated
row actually has.
0 commit comments