refactor(v2): hoist LP state (wallet, mode, autoforward) into dca_lp table
LP-level preferences were denormalised across every `dca_clients` row
of a given user. Every LP enrolment carried its own wallet_id /
dca_mode / fixed_mode_daily_limit / autoforward_ln_address /
autoforward_enabled — and satmachineclient's `update_lp_autoforward`
did a multi-row UPDATE to keep them in sync. That sync dance was the
smell: user-level intent stored at machine-enrolment granularity.
New shape:
dca_lp (user_id PK, dca_wallet_id, default_dca_mode,
fixed_mode_daily_limit, autoforward_ln_address,
autoforward_enabled, ...)
dca_clients (id, machine_id, user_id, username, status, ...)
// pure (machine, LP) enrolment — wallet/mode/autoforward gone
Authority split:
- LP writes dca_lp via satmachineclient (Phase 2, separate commit).
- Operator writes dca_clients via satmachineadmin. They cannot
choose the LP's destination wallet — it's resolved from dca_lp
at distribution time. Better trust hygiene.
Onboarding gate:
- `api_create_deposit` refuses (HTTP 422) when the target LP has
no dca_lp row. Forces every LP through a "yes, I am here and
this is where I want my sats" gesture via satmachineclient
before any fiat starts accumulating against them.
Schema:
- m001 canonical schema updated: slim `dca_clients`, new `dca_lp`.
Fresh installs land here directly.
- m004 idempotent migration for installs that already have the
legacy `dca_clients.wallet_id` column: creates dca_lp,
backfills from the latest dca_clients row per user (window
function), then DROP COLUMN on the moved fields. Greg's live
test data survives the upgrade.
Distribution:
- `get_flow_mode_clients_for_machine` INNER JOINs dca_lp so
un-onboarded LPs are filtered out (no destination wallet).
- `_pay_one_dca_leg`, `_attempt_autoforward`, `settle_lp_balance`
all fetch `dca_lp` via the new `get_dca_lp(user_id)` helper.
Wallet + autoforward read from prefs, not from client.
Models:
- `DcaClient` loses 5 fields. `CreateDcaClientData` reduces to
(machine_id, user_id, username). `UpdateDcaClientData` keeps
only operator-controlled fields (username, status).
- New `DcaLpPreferences` + `UpsertDcaLpData` models for the
per-user surface (satmachineclient writes these in Phase 2).
CRUD:
- New: `get_dca_lp`, `lp_is_onboarded`, `upsert_dca_lp` (the
latter takes a `fallback_wallet_id` for first-onboarding when
satmachineclient auto-seeds from the LP's default LNbits wallet).
- `create_dca_client` insert reduces to the new column set.
Tests: 86 unit tests still green.
Next:
- Phase 1c (this repo): UI simplification for operator's
Add/Edit LP dialogs + deposit-gating UX.
- Phase 2 (satmachineclient): own dca_lp writes + auto-init with
the LP's default LNbits wallet on first dashboard visit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1feaba80ed
commit
80b5a6d785
5 changed files with 307 additions and 49 deletions
119
migrations.py
119
migrations.py
|
|
@ -102,20 +102,17 @@ async def m001_satmachine_v2_initial(db):
|
|||
"ON dca_machines (wallet_id)"
|
||||
)
|
||||
|
||||
# 4. dca_clients — LP registrations scoped per (machine, user). An LP
|
||||
# can hold positions at many machines (and many operators) on the
|
||||
# same LNbits instance.
|
||||
# 4. dca_clients — per-(machine, LP) registrations. Pure machine
|
||||
# enrolment record: no wallet, no mode, no autoforward — those are
|
||||
# LP-controlled at the user level via dca_lp (see below). Operator
|
||||
# just decides "this LP is enrolled at my machine"; everything
|
||||
# delivery-related is the LP's own preference.
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS satoshimachine.dca_clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
machine_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
wallet_id TEXT NOT NULL,
|
||||
username TEXT,
|
||||
dca_mode TEXT NOT NULL DEFAULT 'flow',
|
||||
fixed_mode_daily_limit DECIMAL(10,2),
|
||||
autoforward_ln_address TEXT,
|
||||
autoforward_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
|
|
@ -126,9 +123,35 @@ async def m001_satmachine_v2_initial(db):
|
|||
"ON dca_clients (machine_id, user_id)"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS dca_clients_user_idx " "ON dca_clients (user_id)"
|
||||
"CREATE INDEX IF NOT EXISTS dca_clients_user_idx ON dca_clients (user_id)"
|
||||
)
|
||||
|
||||
# 4a. dca_lp — LP-level (per-user) DCA preferences. ONE row per LNbits
|
||||
# user that has onboarded as a Liquidity Provider, regardless of
|
||||
# how many machines they're enrolled at. Owned by the LP (writes
|
||||
# come from the satmachineclient extension under the LP's session),
|
||||
# read by satmachineadmin during distribution to resolve "where do
|
||||
# DCA payouts for this LP go?"
|
||||
#
|
||||
# Gating: satmachineadmin refuses to create deposits for an LP who
|
||||
# doesn't have a dca_lp row yet. The LP must onboard via
|
||||
# satmachineclient first (which auto-creates the row with their
|
||||
# default LNbits wallet on first dashboard visit). Forces every
|
||||
# LP through a "yes, I am here and this is where I want my sats"
|
||||
# gesture before any fiat starts accumulating against them.
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS satoshimachine.dca_lp (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
dca_wallet_id TEXT NOT NULL,
|
||||
default_dca_mode TEXT NOT NULL DEFAULT 'flow',
|
||||
fixed_mode_daily_limit DECIMAL(10,2),
|
||||
autoforward_ln_address TEXT,
|
||||
autoforward_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
""")
|
||||
|
||||
# 5. dca_deposits — fiat the operator (or super) records against an LP
|
||||
# at a machine. creator_user_id preserves audit trail.
|
||||
await db.execute(f"""
|
||||
|
|
@ -336,3 +359,81 @@ async def m003_rename_settlements_net_sats_to_principal_sats(db):
|
|||
"ALTER TABLE satoshimachine.dca_settlements "
|
||||
"RENAME COLUMN net_sats TO principal_sats"
|
||||
)
|
||||
|
||||
|
||||
async def m004_introduce_dca_lp_table(db):
|
||||
"""Hoist LP-level state (wallet, mode, autoforward) out of dca_clients
|
||||
into a per-user dca_lp table. dca_clients becomes a pure (machine, LP)
|
||||
enrolment record; everything delivery-related becomes the LP's own
|
||||
preference, owned and written by satmachineclient.
|
||||
|
||||
Why: the per-row state on dca_clients was a denormalised duplicate of
|
||||
user-level intent ("which wallet should my DCA land in?" + "should it
|
||||
forward to my LN address?" — same answer regardless of which machine
|
||||
paid). Today's update_lp_autoforward already does a multi-row UPDATE
|
||||
to keep the rows in sync — a smell of state belonging one level up.
|
||||
|
||||
Fresh installs from m001 onward land on the new schema directly.
|
||||
Existing installs (pre-m004 test data) get migrated here:
|
||||
1. Create dca_lp table (no-op if already present from m001 path).
|
||||
2. Backfill dca_lp from existing dca_clients rows, picking the
|
||||
most-recently-updated row per user_id when an LP is enrolled at
|
||||
multiple machines.
|
||||
3. Drop the moved columns from dca_clients.
|
||||
|
||||
Idempotent: probes for the legacy `dca_clients.wallet_id` column. If
|
||||
absent the install already on the new shape; no-op.
|
||||
"""
|
||||
try:
|
||||
await db.fetchone("SELECT wallet_id FROM satoshimachine.dca_clients LIMIT 1")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Step 1: create dca_lp if it doesn't exist yet. m001 on a fresh install
|
||||
# already created it; on a pre-m004 install we're creating it here.
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS satoshimachine.dca_lp (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
dca_wallet_id TEXT NOT NULL,
|
||||
default_dca_mode TEXT NOT NULL DEFAULT 'flow',
|
||||
fixed_mode_daily_limit DECIMAL(10,2),
|
||||
autoforward_ln_address TEXT,
|
||||
autoforward_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
""")
|
||||
|
||||
# Step 2: backfill dca_lp from dca_clients. Pick the latest row per
|
||||
# user (by updated_at, falling back to created_at) when the LP is
|
||||
# enrolled at multiple machines — that row reflects their most
|
||||
# recent intent. ROW_NUMBER() OVER (...) requires SQLite 3.25+ (2018).
|
||||
await db.execute("""
|
||||
INSERT OR IGNORE INTO satoshimachine.dca_lp
|
||||
(user_id, dca_wallet_id, default_dca_mode, fixed_mode_daily_limit,
|
||||
autoforward_ln_address, autoforward_enabled,
|
||||
created_at, updated_at)
|
||||
SELECT user_id, wallet_id, dca_mode, fixed_mode_daily_limit,
|
||||
autoforward_ln_address, autoforward_enabled,
|
||||
created_at, updated_at
|
||||
FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (
|
||||
PARTITION BY user_id
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
) AS rn
|
||||
FROM satoshimachine.dca_clients
|
||||
) ranked
|
||||
WHERE rn = 1
|
||||
""")
|
||||
|
||||
# Step 3: drop the moved columns from dca_clients. ALTER TABLE DROP
|
||||
# COLUMN needs SQLite 3.35+ (2021). One column per ALTER (SQLite
|
||||
# doesn't support multi-column DROP).
|
||||
for col in (
|
||||
"wallet_id",
|
||||
"dca_mode",
|
||||
"fixed_mode_daily_limit",
|
||||
"autoforward_ln_address",
|
||||
"autoforward_enabled",
|
||||
):
|
||||
await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue