File size: 8,865 Bytes
1fcc069
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""
scripts/migrate_sqlite_to_postgres.py

One-off migration of the old local SQLite database (data/nichie.db) into
PostgreSQL. The source file is opened strictly read-only, so nothing in the
original database is modified.

Usage (from the project root, with DATABASE_URL set):
    python scripts/migrate_sqlite_to_postgres.py --source data/nichie.db
    python scripts/migrate_sqlite_to_postgres.py --source data/nichie.db --dry-run

Safe to re-run: rows whose primary key already exists are skipped. Row ids and
timestamps are preserved, so the chat history keeps its original order.
"""

import argparse
import asyncio
import logging
import sqlite3
import sys
from datetime import date, datetime
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("migrate-sqlite")

VALID_TIMEZONES = {"Asia/Jakarta", "Asia/Makassar", "Asia/Jayapura"}

# Table order respects the foreign key graph: parents first.
TABLES = [
    ("users", ["user_id", "name", "username", "timezone", "created_at"]),
    ("diet_profiles", [
        "id", "user_id", "age", "gender", "height_cm", "weight_kg",
        "goal", "activity_level", "updated_at",
    ]),
    ("meal_schedules", ["id", "user_id", "meal_type", "time", "enabled"]),
    ("food_logs", [
        "id", "user_id", "date", "meal_type", "food_name", "amount_g",
        "calories", "protein_g", "carbs_g", "fat_g", "logged_at",
    ]),
    ("conversations", ["id", "user_id", "role", "content", "created_at"]),
    ("user_activities", [
        "id", "user_id", "title", "description", "activity_dt",
        "remind_mins", "reminded", "done", "done_at", "created_at",
    ]),
]


def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]:
    """Columns that actually exist in the source table."""
    try:
        rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
    except sqlite3.OperationalError:
        return []
    return [row[1] for row in rows]


def _read_sqlite(source: Path) -> dict[str, list[dict]]:
    uri = f"file:{source.as_posix()}?mode=ro"
    conn = sqlite3.connect(uri, uri=True)
    conn.row_factory = sqlite3.Row
    data: dict[str, list[dict]] = {}

    try:
        for table, columns in TABLES:
            available = _table_columns(conn, table)
            if not available:
                logger.info("Skipping %s: table not present", table)
                continue

            # Only read columns the source actually has; anything new in the
            # Postgres schema (e.g. done/done_at) falls back to the DB default.
            selected = [c for c in columns if c in available]
            if not selected:
                logger.info("Skipping %s: no matching columns", table)
                continue

            missing = [c for c in columns if c not in available]
            if missing:
                logger.info("  %s: %s not in source, using defaults", table, ", ".join(missing))

            try:
                rows = conn.execute(
                    f"SELECT {', '.join(selected)} FROM {table} ORDER BY 1"
                ).fetchall()
            except sqlite3.OperationalError as exc:
                logger.info("Skipping %s: %s", table, exc)
                continue

            data[table] = [
                {column: _clean(column, row[column]) for column in selected}
                for row in rows
            ]
    finally:
        conn.close()

    return data


def _clean(column: str, value):
    """Coerce SQLite values into something Postgres will accept."""
    if value is None:
        return None

    # Boolean columns are stored as 0/1 in SQLite.
    if column in ("enabled", "reminded", "done"):
        return bool(value)

    # asyncpg rejects ISO strings for DATE/TIMESTAMPTZ parameters.
    if column in ("date", "created_at", "updated_at", "logged_at", "activity_dt", "done_at"):
        if column == "date":
            try:
                return date.fromisoformat(str(value)[:10])
            except ValueError:
                return value
        if isinstance(value, (int, float)):
            return datetime.utcfromtimestamp(value)
        text = str(value).strip().replace("T", " ")
        parsed = None
        for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
            try:
                parsed = datetime.strptime(text[:19], fmt)
                break
            except ValueError:
                continue
        if parsed is not None:
            # SQLite stores UTC; tag it as UTC so Postgres converts correctly.
            from datetime import timezone
            return parsed.replace(tzinfo=timezone.utc)
        return text

    # Guard against unknown timezones that pytz/Postgres would reject.
    if column == "timezone" and str(value) not in VALID_TIMEZONES:
        logger.warning("Unknown timezone %r for user, falling back to Asia/Jakarta", value)
        return "Asia/Jakarta"

    return value


async def _migrate(source: Path, database_url: str, dry_run: bool) -> bool:
    import asyncpg

    # Make sure the target schema (including all pending migrations) exists
    # before copying anything in.
    from database.connection import close_db, init_db
    from database.migrations import run_migrations

    await init_db()
    applied = await run_migrations()
    if applied:
        logger.info("Applied migration(s): %s", ", ".join(applied))

    data = _read_sqlite(source)

    for table, _ in TABLES:
        count = len(data.get(table, []))
        logger.info("  %-18s %d row(s)", table, count)

    if dry_run:
        logger.info("Dry run — nothing written.")
        await close_db()
        return True

    conn = await asyncpg.connect(database_url)

    # Tables with a GENERATED BY DEFAULT identity need explicit id inserts.
    identity_tables = {"diet_profiles", "meal_schedules", "food_logs", "conversations", "user_activities"}

    inserted_total = 0
    try:
        for table, columns in TABLES:
            rows = data.get(table, [])
            if not rows:
                continue

            # Build the column list from the keys actually read for this table,
            # so missing source columns fall back to the Postgres defaults.
            column_names = list(rows[0].keys())
            placeholders = ", ".join(f"${i + 1}" for i in range(len(column_names)))
            column_list = ", ".join(column_names)
            sql = f"INSERT INTO {table} ({column_list}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"

            # One row at a time: volumes are tiny and this keeps the conflict
            # semantics obvious and any failure pinpointable.
            for row in rows:
                values = tuple(row[column] for column in column_names)
                await conn.execute(sql, *values)
                inserted_total += 1

            if table in identity_tables:
                # Keep the identity sequence ahead of the migrated ids.
                await conn.execute(
                    f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), "
                    f"GREATEST((SELECT COALESCE(MAX(id), 0) FROM {table}), 1), true)"
                )
                logger.info("  %s: identity sequence advanced", table)

        logger.info("Migration complete. %d statement(s) executed.", inserted_total)
    finally:
        await conn.close()
        await close_db()

    return True


def main() -> int:
    parser = argparse.ArgumentParser(description="Migrate SQLite data to PostgreSQL.")
    parser.add_argument(
        "--source",
        default=str(PROJECT_ROOT / "data" / "nichie.db"),
        help="Path to the SQLite database (opened read-only)",
    )
    parser.add_argument(
        "--dry-run", action="store_true", help="Report row counts without writing"
    )
    args = parser.parse_args()

    source = Path(args.source).resolve()
    if not source.exists():
        logger.error("Source database not found: %s", source)
        return 1

    from config.settings import DATABASE_URL

    logger.info("Source: %s", source)
    logger.info("Target: %s", _mask(DATABASE_URL))

    ok = asyncio.run(_migrate(source, DATABASE_URL, args.dry_run))
    return 0 if ok else 1


def _mask(url: str) -> str:
    """Hide the password in a log line."""
    if "@" not in url or "://" not in url:
        return url
    scheme, rest = url.split("://", 1)
    if "@" in rest:
        credentials, host = rest.rsplit("@", 1)
        if ":" in credentials:
            user = credentials.split(":", 1)[0]
            return f"{scheme}://{user}:***@{host}"
    return url


if __name__ == "__main__":
    raise SystemExit(main())