mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
- Updated database queries to check for column existence with schema context. - Modified credit purchase quantity limits to allow up to 10,000 credits. - Improved user interface for credit purchases, enabling custom amounts and clamping input values. - Adjusted FAQ content to clarify credit purchasing process.
235 lines
8.5 KiB
Python
235 lines
8.5 KiB
Python
"""unify page limits and premium credits into a single credit_micros_balance wallet
|
|
|
|
Collapses the two separate economies (ETL ``pages_limit``/``pages_used`` and
|
|
premium ``premium_credit_micros_limit``/``premium_credit_micros_used``) into one
|
|
USD-micro wallet column ``user.credit_micros_balance`` that decreases on use and
|
|
increases on purchase / grant. ``premium_credit_micros_reserved`` is kept (renamed
|
|
to ``credit_micros_reserved``) for in-flight reservation holds.
|
|
|
|
Backfill (per existing user row):
|
|
|
|
balance = GREATEST(0, premium_credit_micros_limit - premium_credit_micros_used)
|
|
+ (CASE WHEN pages_limit < 100000000
|
|
THEN GREATEST(0, pages_limit - pages_used) * 1000
|
|
ELSE 0 END)
|
|
|
|
The ``pages_limit < 100000000`` guard skips the OSS "unlimited" default
|
|
(``PAGES_LIMIT=999999999``) so self-hosters don't get a ~$1M credit grant.
|
|
1 page == 1000 micros == $0.001 (matches the prior $1 / 1000 pages price).
|
|
|
|
Table / type renames:
|
|
|
|
premium_token_purchases -> credit_purchases
|
|
premiumtokenpurchasestatus (enum)-> creditpurchasestatus
|
|
user_incentive_tasks.pages_awarded -> credit_micros_awarded (backfilled * 1000)
|
|
|
|
The "user" table is in zero_publication's column list, so this migration updates
|
|
the publication via ``apply_publication`` (canonical reconcile, per migration 155)
|
|
BEFORE dropping the old columns it referenced.
|
|
|
|
IMPORTANT - before AND after running this migration (same as migration 140):
|
|
1. Stop zero-cache (it holds replication locks that will deadlock DDL)
|
|
2. Run: alembic upgrade head
|
|
3. Delete / reset the zero-cache data volume
|
|
4. Restart zero-cache (it will do a fresh initial sync)
|
|
|
|
Revision ID: 156
|
|
Revises: 155
|
|
"""
|
|
|
|
from collections.abc import Sequence
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from alembic import op
|
|
from app.zero_publication import apply_publication
|
|
|
|
revision: str = "156"
|
|
down_revision: str | None = "155"
|
|
branch_labels: str | Sequence[str] | None = None
|
|
depends_on: str | Sequence[str] | None = None
|
|
|
|
|
|
def _column_exists(conn, table: str, column: str) -> bool:
|
|
return (
|
|
conn.execute(
|
|
sa.text(
|
|
"SELECT 1 FROM information_schema.columns "
|
|
"WHERE table_name = :tbl AND column_name = :col "
|
|
"AND table_schema = current_schema()"
|
|
),
|
|
{"tbl": table, "col": column},
|
|
).fetchone()
|
|
is not None
|
|
)
|
|
|
|
|
|
def _table_exists(conn, table: str) -> bool:
|
|
return (
|
|
conn.execute(
|
|
sa.text(
|
|
"SELECT 1 FROM information_schema.tables "
|
|
"WHERE table_name = :tbl AND table_schema = current_schema()"
|
|
),
|
|
{"tbl": table},
|
|
).fetchone()
|
|
is not None
|
|
)
|
|
|
|
|
|
def _terminate_blocked_pids(conn, table: str) -> None:
|
|
"""Kill backends whose locks on *table* would block our AccessExclusiveLock."""
|
|
conn.execute(
|
|
sa.text(
|
|
"SELECT pg_terminate_backend(l.pid) "
|
|
"FROM pg_locks l "
|
|
"JOIN pg_class c ON c.oid = l.relation "
|
|
"WHERE c.relname = :tbl "
|
|
" AND l.pid != pg_backend_pid()"
|
|
),
|
|
{"tbl": table},
|
|
)
|
|
|
|
|
|
def upgrade() -> None:
|
|
conn = op.get_bind()
|
|
|
|
# ------------------------------------------------------------------
|
|
# 1. Add credit_micros_balance + backfill from both legacy economies.
|
|
# ------------------------------------------------------------------
|
|
if not _column_exists(conn, "user", "credit_micros_balance"):
|
|
op.add_column(
|
|
"user",
|
|
sa.Column(
|
|
"credit_micros_balance",
|
|
sa.BigInteger(),
|
|
nullable=False,
|
|
server_default="5000000",
|
|
),
|
|
)
|
|
|
|
# Backfill only when ALL legacy source columns are present (fresh DBs
|
|
# created from current models won't have them).
|
|
if all(
|
|
_column_exists(conn, "user", col)
|
|
for col in (
|
|
"premium_credit_micros_limit",
|
|
"premium_credit_micros_used",
|
|
"pages_limit",
|
|
"pages_used",
|
|
)
|
|
):
|
|
conn.execute(
|
|
sa.text(
|
|
'UPDATE "user" SET credit_micros_balance = '
|
|
"GREATEST(0, premium_credit_micros_limit - premium_credit_micros_used) "
|
|
"+ (CASE WHEN pages_limit < 100000000 "
|
|
" THEN GREATEST(0, pages_limit - pages_used) * 1000 "
|
|
" ELSE 0 END)"
|
|
)
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 2. Rename premium_credit_micros_reserved -> credit_micros_reserved.
|
|
# ------------------------------------------------------------------
|
|
if _column_exists(
|
|
conn, "user", "premium_credit_micros_reserved"
|
|
) and not _column_exists(conn, "user", "credit_micros_reserved"):
|
|
op.alter_column(
|
|
"user",
|
|
"premium_credit_micros_reserved",
|
|
new_column_name="credit_micros_reserved",
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 3. Reconcile the Zero publication to the new column list
|
|
# (id, credit_micros_balance) BEFORE dropping the columns it used
|
|
# to reference, otherwise Postgres rejects the column drops with
|
|
# "cannot drop column ... referenced by publication".
|
|
# ------------------------------------------------------------------
|
|
conn.execute(sa.text("SET lock_timeout = '10s'"))
|
|
_terminate_blocked_pids(conn, "user")
|
|
apply_publication(conn)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 4. Drop the legacy quota columns now that nothing references them.
|
|
# ------------------------------------------------------------------
|
|
for col in (
|
|
"premium_credit_micros_limit",
|
|
"premium_credit_micros_used",
|
|
"pages_limit",
|
|
"pages_used",
|
|
):
|
|
if _column_exists(conn, "user", col):
|
|
op.drop_column("user", col)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 5. Rename premium_token_purchases -> credit_purchases and its enum.
|
|
# ------------------------------------------------------------------
|
|
op.execute(
|
|
"""
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM pg_type t
|
|
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
WHERE t.typname = 'premiumtokenpurchasestatus'
|
|
AND n.nspname = current_schema()
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM pg_type t
|
|
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
WHERE t.typname = 'creditpurchasestatus'
|
|
AND n.nspname = current_schema()
|
|
)
|
|
THEN
|
|
ALTER TYPE premiumtokenpurchasestatus RENAME TO creditpurchasestatus;
|
|
END IF;
|
|
END
|
|
$$;
|
|
"""
|
|
)
|
|
|
|
if _table_exists(conn, "premium_token_purchases") and not _table_exists(
|
|
conn, "credit_purchases"
|
|
):
|
|
op.rename_table("premium_token_purchases", "credit_purchases")
|
|
|
|
# ``source`` distinguishes user checkout from auto-reload top-ups.
|
|
if _table_exists(conn, "credit_purchases") and not _column_exists(
|
|
conn, "credit_purchases", "source"
|
|
):
|
|
op.add_column(
|
|
"credit_purchases",
|
|
sa.Column(
|
|
"source",
|
|
sa.String(length=20),
|
|
nullable=False,
|
|
server_default="checkout",
|
|
),
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 6. Rename user_incentive_tasks.pages_awarded -> credit_micros_awarded
|
|
# and convert page counts to micros (1 page == 1000 micros).
|
|
# ------------------------------------------------------------------
|
|
if _column_exists(
|
|
conn, "user_incentive_tasks", "pages_awarded"
|
|
) and not _column_exists(conn, "user_incentive_tasks", "credit_micros_awarded"):
|
|
op.alter_column(
|
|
"user_incentive_tasks",
|
|
"pages_awarded",
|
|
new_column_name="credit_micros_awarded",
|
|
type_=sa.BigInteger(),
|
|
)
|
|
conn.execute(
|
|
sa.text(
|
|
"UPDATE user_incentive_tasks "
|
|
"SET credit_micros_awarded = credit_micros_awarded * 1000"
|
|
)
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
"""No-op. This is a one-way data-model unification; the legacy split
|
|
columns cannot be faithfully reconstructed from a single balance."""
|