File size: 13,068 Bytes
48d895c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
"""First-boot data migration.



Config migration

----------------

local   : seeds ``${DATA_DIR}/config.toml`` from ``config.defaults.toml`` if

          the file does not exist yet β€” gives users an editable copy on first run.

redis / sql : if the backend is empty (version == 0) AND

          ``${DATA_DIR}/config.toml`` exists, migrates the user overrides into

          the DB backend. If it does not exist either, nothing is written

          (defaults are always loaded from ``config.defaults.toml`` at runtime).



Account migration

-----------------

Runs only when ACCOUNT_STORAGE != "local".

If ``${DATA_DIR}/accounts.db`` (the previous local SQLite store) exists AND the

target backend is empty (revision == 0), all accounts are copied into the

new backend β€” preserving pool, status, quota, usage stats, and timestamps.

After a successful migration the SQLite file is renamed to

``${DATA_DIR}/accounts.db.migrated`` so the same migration is never re-run.

"""

from __future__ import annotations

import asyncio
import shutil
from pathlib import Path
from typing import TYPE_CHECKING

from loguru import logger

from app.platform.paths import data_path

if TYPE_CHECKING:
    from app.control.account.commands import AccountPatch
    from app.control.account.repository import AccountRepository
    from app.platform.config.backends.base import ConfigBackend

_BASE_DIR     = Path(__file__).resolve().parents[3]
_DEFAULTS_PATH = _BASE_DIR / "config.defaults.toml"
_USER_CFG_PATH = data_path("config.toml")
_LOCAL_DB_PATH = data_path("accounts.db")
_BATCH         = 500  # accounts per upsert/patch batch


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------

async def run_startup_migrations(

    config_backend: "ConfigBackend",

    account_repo: "AccountRepository",

) -> None:
    """Run all first-boot migrations.  Safe to call on every startup."""
    await _migrate_config(config_backend)
    await _migrate_basic_refresh_interval(config_backend)
    await _migrate_accounts(account_repo)
    await _backfill_grok_4_3_quota(account_repo)
    await _normalize_basic_fast_only_quota(account_repo)
    await _backfill_console_quota(account_repo)


# ---------------------------------------------------------------------------
# Config migration
# ---------------------------------------------------------------------------

async def _migrate_config(backend: "ConfigBackend") -> None:
    from app.platform.config.backends.factory import get_config_backend_name
    from app.platform.config.loader import load_toml

    backend_name = get_config_backend_name()

    if backend_name == "local":
        # Seed ${DATA_DIR}/config.toml from defaults so users have an editable file.
        if not _USER_CFG_PATH.exists() and _DEFAULTS_PATH.exists():
            await asyncio.to_thread(shutil.copy2, _DEFAULTS_PATH, _USER_CFG_PATH)
            logger.info("config: seeded {} from config.defaults.toml", _USER_CFG_PATH)
        return

    # DB / Redis backends β€” migrate only if backend is empty.
    if await backend.version() != 0:
        return  # already has data, skip

    if _USER_CFG_PATH.exists():
        user_data = await asyncio.to_thread(load_toml, _USER_CFG_PATH)
        if user_data:
            await backend.apply_patch(user_data)
            logger.info(
                "config: migrated {} -> {} backend ({} keys)",
                _USER_CFG_PATH,
                backend_name,
                _count_keys(user_data),
            )
            return

    logger.debug("config: {} backend is empty, no local overrides to migrate", backend_name)


async def _migrate_basic_refresh_interval(backend: "ConfigBackend") -> None:
    data = await backend.load()
    account = data.get("account", {})
    refresh = account.get("refresh", {}) if isinstance(account, dict) else {}
    value = refresh.get("basic_interval_sec") if isinstance(refresh, dict) else None
    try:
        old_default = int(value)
    except (TypeError, ValueError):
        return
    if old_default != 36_000:
        return
    await backend.apply_patch({"account": {"refresh": {"basic_interval_sec": 86_400}}})
    logger.info("config: updated basic refresh interval default from 36000s to 86400s")


# ---------------------------------------------------------------------------
# Account migration
# ---------------------------------------------------------------------------

async def _migrate_accounts(target_repo: "AccountRepository") -> None:
    from app.control.account.backends.factory import get_repository_backend

    if get_repository_backend() == "local":
        return  # already on local, nothing to migrate

    sqlite_path = _LOCAL_DB_PATH
    if not sqlite_path.exists():
        return  # no previous local data

    # Check whether the target already has data.
    snapshot = await target_repo.runtime_snapshot()
    if snapshot.revision > 0 or snapshot.items:
        logger.debug("account: target backend not empty (revision={}), skipping migration", snapshot.revision)
        return

    logger.info("account: migrating accounts from {} β†’ {} backend", sqlite_path, get_repository_backend())
    count = await _copy_accounts(sqlite_path, target_repo)

    # Rename the SQLite file so this migration is never re-run.
    done_path = sqlite_path.with_suffix(".db.migrated")
    await asyncio.to_thread(sqlite_path.rename, done_path)
    logger.info("account: migration complete ({} accounts), renamed {} β†’ {}", count, sqlite_path.name, done_path.name)


async def _copy_accounts(sqlite_path: Path, target: "AccountRepository") -> int:
    """Read all accounts from the local SQLite file and write to *target*."""
    from app.control.account.backends.local import LocalAccountRepository
    from app.control.account.commands import AccountUpsert, ListAccountsQuery

    source = LocalAccountRepository(sqlite_path)
    await source.initialize()

    total = 0
    page = 1

    try:
        while True:
            result = await source.list_accounts(
                ListAccountsQuery(page=page, page_size=_BATCH, include_deleted=True)
            )
            records = result.items
            if not records:
                break

            # Step 1: upsert β€” creates records with token / pool / tags / ext.
            upserts = [
                AccountUpsert(token=r.token, pool=r.pool, tags=r.tags, ext=r.ext)
                for r in records
            ]
            await target.upsert_accounts(upserts)

            # Step 2: patch β€” fills status, quota, usage counters, timestamps.
            patches = [_record_to_patch(r) for r in records]
            await target.patch_accounts(patches)

            # Step 3: soft-delete records that were deleted in the source.
            deleted_tokens = [r.token for r in records if r.deleted_at is not None]
            if deleted_tokens:
                await target.delete_accounts(deleted_tokens)

            total += len(records)
            if page >= result.total_pages:
                break
            page += 1
    finally:
        await source.close()

    return total


def _record_to_patch(r) -> "AccountPatch":
    from app.control.account.commands import AccountPatch

    qs = r.quota_set()
    return AccountPatch(
        token=r.token,
        status=r.status,
        quota_auto=qs.auto.to_dict()   if qs.auto   else None,
        quota_fast=qs.fast.to_dict()   if qs.fast   else None,
        quota_expert=qs.expert.to_dict() if qs.expert else None,
        quota_heavy=qs.heavy.to_dict()    if qs.heavy    else None,
        quota_grok_4_3=qs.grok_4_3.to_dict() if qs.grok_4_3 else None,
        quota_console=qs.console.to_dict() if qs.console else None,
        # Usage counts β€” target starts at 0, so actual value == delta.
        usage_use_delta=r.usage_use_count   or None,
        usage_fail_delta=r.usage_fail_count or None,
        usage_sync_delta=r.usage_sync_count or None,
        last_use_at=r.last_use_at,
        last_fail_at=r.last_fail_at,
        last_fail_reason=r.last_fail_reason,
        last_sync_at=r.last_sync_at,
        last_clear_at=r.last_clear_at,
        state_reason=r.state_reason,
        ext_merge=r.ext or None,
    )


# ---------------------------------------------------------------------------
# Backfill quota_grok_4_3 for super/heavy accounts imported before the field existed.
# ---------------------------------------------------------------------------

async def _backfill_grok_4_3_quota(repo: "AccountRepository") -> None:
    from app.control.account.commands import AccountPatch, ListAccountsQuery
    from app.control.account.quota_defaults import default_quota_window

    patches: list[AccountPatch] = []
    page = 1
    while True:
        result = await repo.list_accounts(
            ListAccountsQuery(page=page, page_size=_BATCH, include_deleted=False)
        )
        for record in result.items:
            if record.pool not in ("super", "heavy"):
                continue
            if record.quota_set().grok_4_3 is not None:
                continue
            window = default_quota_window(record.pool, 4)
            if window is None:
                continue
            patches.append(AccountPatch(token=record.token, quota_grok_4_3=window.to_dict()))
        if page >= result.total_pages:
            break
        page += 1

    if not patches:
        return

    total = 0
    for i in range(0, len(patches), _BATCH):
        batch = patches[i : i + _BATCH]
        res = await repo.patch_accounts(batch)
        total += res.patched
    logger.info("account: backfilled quota_grok_4_3 for {} super/heavy accounts", total)


async def _normalize_basic_fast_only_quota(repo: "AccountRepository") -> None:
    from app.control.account.commands import AccountPatch, ListAccountsQuery
    from app.control.account.quota_defaults import normalize_quota_set

    patches: list[AccountPatch] = []
    page = 1
    while True:
        result = await repo.list_accounts(
            ListAccountsQuery(
                page=page,
                page_size=_BATCH,
                pool="basic",
                include_deleted=False,
            )
        )
        for record in result.items:
            normalized = normalize_quota_set("basic", record.quota_set())
            if normalized.to_dict() == record.quota_set().to_dict():
                continue
            patches.append(
                AccountPatch(
                    token=record.token,
                    quota_auto=normalized.auto.to_dict(),
                    quota_fast=normalized.fast.to_dict(),
                    quota_expert=normalized.expert.to_dict(),
                )
            )
        if page >= result.total_pages:
            break
        page += 1

    if not patches:
        return

    total = 0
    for i in range(0, len(patches), _BATCH):
        batch = patches[i : i + _BATCH]
        res = await repo.patch_accounts(batch)
        total += res.patched
    logger.info("account: normalized {} basic accounts to fast-only quota", total)


async def _backfill_console_quota(repo: "AccountRepository") -> None:
    """Backfill quota_console for all accounts that don't have it yet."""
    from app.control.account.commands import AccountPatch, ListAccountsQuery
    from app.control.account.quota_defaults import default_quota_window

    patches: list[AccountPatch] = []
    page = 1
    while True:
        result = await repo.list_accounts(
            ListAccountsQuery(page=page, page_size=_BATCH, include_deleted=False)
        )
        for record in result.items:
            if record.quota_set().console is not None:
                continue
            window = default_quota_window(record.pool, 5)
            if window is None:
                continue
            patches.append(AccountPatch(token=record.token, quota_console=window.to_dict()))
        if page >= result.total_pages:
            break
        page += 1

    if not patches:
        return

    total = 0
    for i in range(0, len(patches), _BATCH):
        batch = patches[i : i + _BATCH]
        res = await repo.patch_accounts(batch)
        total += res.patched
    logger.info("account: backfilled quota_console for {} accounts", total)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _count_keys(nested: dict, prefix: str = "") -> int:
    count = 0
    for v in nested.values():
        if isinstance(v, dict):
            count += _count_keys(v)
        else:
            count += 1
    return count