New Database
This commit is contained in:
+273
-200
@@ -1,23 +1,31 @@
|
||||
"""SQLite database models, initialization, and user-scoped helpers.
|
||||
"""PostgreSQL database models, initialization, and user-scoped helpers.
|
||||
|
||||
Multi-user design:
|
||||
Multi-user design (unchanged from the SQLite era):
|
||||
- The `users` table stores every account. Root (ID 0) is the administrator and
|
||||
is created automatically on first boot (fresh DB) or when migrating an
|
||||
existing single-user database (all pre-existing data becomes Root's data).
|
||||
- Every per-user table carries a `user_id INTEGER NOT NULL DEFAULT 0` column,
|
||||
so legacy single-user rows are automatically attributed to Root on migration
|
||||
so legacy single-user rows are automatically attributed to Root on import
|
||||
and any NEW column added in the future gets a DEFAULT (the migration fills
|
||||
missing columns with their SCHEMA defaults).
|
||||
- `auth_config` is keyed by `user_id` (legacy `id=1` row maps to user 0).
|
||||
|
||||
The data-access layer talks PostgreSQL through ``backend.pg_compat``, a thin
|
||||
sqlite3-compatible shim (``?`` placeholders, ``INSERT OR IGNORE``,
|
||||
``datetime('now')``, ``cursor.lastrowid``, ``conn.total_changes`` are all
|
||||
translated transparently).
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from backend.pg_compat import connect as _pg_connect
|
||||
|
||||
# ─── PostgreSQL schema ───────────────────────
|
||||
# All new columns must carry a DEFAULT (migration contract: missing columns are
|
||||
# filled with their SCHEMA default when the DB is upgraded).
|
||||
SCHEMA = """
|
||||
-- Global schema version (single row). All new columns must carry a DEFAULT.
|
||||
-- Global schema version (single row).
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
@@ -26,7 +34,7 @@ CREATE TABLE IF NOT EXISTS meta (
|
||||
-- Auth tokens (dual-token auth): access (short-lived, per-request) and
|
||||
-- refresh (long-lived, stored in an httpOnly cookie). Only hashes are stored.
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
token_hash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'access' CHECK(kind IN ('access','refresh')),
|
||||
@@ -34,14 +42,14 @@ CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
temp INTEGER NOT NULL DEFAULT 0,
|
||||
session_version INTEGER NOT NULL DEFAULT 0,
|
||||
reauth_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
created_at TEXT DEFAULT (to_char(now(), 'YYYY-MM-DD HH24:MI:SS')),
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
|
||||
is_temp INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -49,7 +57,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','pending')),
|
||||
session_version INTEGER NOT NULL DEFAULT 0,
|
||||
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('dark','light','auto')),
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
created_at TEXT DEFAULT (to_char(now(), 'YYYY-MM-DD HH24:MI:SS'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_config (
|
||||
@@ -64,16 +72,16 @@ CREATE TABLE IF NOT EXISTS auth_config (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#4A90D9',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
created_at TEXT DEFAULT (to_char(now(), 'YYYY-MM-DD HH24:MI:SS'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
group_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('upload', 'subscription', 'manual')),
|
||||
@@ -85,7 +93,7 @@ CREATE TABLE IF NOT EXISTS calendar_sources (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
source_id INTEGER NOT NULL,
|
||||
uid TEXT,
|
||||
@@ -99,7 +107,7 @@ CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
calendar_group_id INTEGER NOT NULL,
|
||||
minutes_before INTEGER DEFAULT 15,
|
||||
@@ -108,7 +116,7 @@ CREATE TABLE IF NOT EXISTS reminders (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
api_key TEXT DEFAULT '',
|
||||
city TEXT DEFAULT 'Beijing',
|
||||
@@ -124,35 +132,35 @@ CREATE TABLE IF NOT EXISTS weather_config (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
updated_at TEXT DEFAULT (to_char(now(), 'YYYY-MM-DD HH24:MI:SS'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather_hourly_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT,
|
||||
fetched_at TEXT DEFAULT (datetime('now'))
|
||||
fetched_at TEXT DEFAULT (to_char(now(), 'YYYY-MM-DD HH24:MI:SS'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather_minutely_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT,
|
||||
fetched_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather_warning_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT,
|
||||
fetched_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clock_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
city TEXT DEFAULT 'Beijing',
|
||||
timezone TEXT DEFAULT 'Asia/Shanghai',
|
||||
@@ -160,7 +168,7 @@ CREATE TABLE IF NOT EXISTS clock_config (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alarms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT DEFAULT '',
|
||||
time TEXT NOT NULL,
|
||||
@@ -171,7 +179,7 @@ CREATE TABLE IF NOT EXISTS alarms (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS countdowns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT DEFAULT '',
|
||||
duration_seconds INTEGER DEFAULT 300,
|
||||
@@ -181,7 +189,7 @@ CREATE TABLE IF NOT EXISTS countdowns (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS display_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
mode TEXT NOT NULL DEFAULT 'default' CHECK(mode IN ('default', 'custom')),
|
||||
custom_html TEXT DEFAULT '',
|
||||
@@ -191,7 +199,7 @@ CREATE TABLE IF NOT EXISTS display_config (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pomodoro_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
running INTEGER DEFAULT 0,
|
||||
phase TEXT DEFAULT 'work' CHECK(phase IN ('work', 'break')),
|
||||
@@ -213,26 +221,27 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_pomodoro_state_user ON pomodoro_state(user
|
||||
"""
|
||||
|
||||
|
||||
# Tables whose rows are completely per-user (used for cascade delete).
|
||||
USER_DATA_TABLES = [
|
||||
"auth_config", "calendar_groups", "calendar_sources", "calendar_events",
|
||||
"reminders", "weather_config", "weather_cache", "weather_hourly_cache",
|
||||
"weather_minutely_cache", "weather_warning_cache", "clock_config",
|
||||
"alarms", "countdowns", "display_config", "pomodoro_state",
|
||||
# Ordered list of all user-data tables (children first) for FK-safe deletion.
|
||||
_DELETE_ORDER = [
|
||||
"calendar_events", "calendar_sources", "reminders", "calendar_groups",
|
||||
"weather_cache", "weather_hourly_cache", "weather_minutely_cache",
|
||||
"weather_warning_cache", "weather_config", "clock_config",
|
||||
"alarms", "countdowns", "display_config", "pomodoro_state", "auth_config",
|
||||
"auth_tokens",
|
||||
]
|
||||
|
||||
|
||||
def get_db(db_path: str = "data.db") -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
def get_db(db_path: str = "data.db"):
|
||||
"""Open a PostgreSQL connection (sqlite3-compatible wrapper).
|
||||
|
||||
`db_path` is a PostgreSQL connection string (kept as the historical
|
||||
parameter name to minimise churn across the codebase).
|
||||
"""
|
||||
return _pg_connect(db_path)
|
||||
|
||||
|
||||
def _db_path(preferred: str = None) -> str:
|
||||
"""Get the actual database path, preferring current_app config if available."""
|
||||
"""Get the actual database connection string, preferring current_app config."""
|
||||
if preferred:
|
||||
return preferred
|
||||
try:
|
||||
@@ -330,16 +339,8 @@ def delete_user(user_id: int, db_path: str = None):
|
||||
if user_id == 0:
|
||||
return
|
||||
conn = get_db(_db_path(db_path))
|
||||
# Children first so FK constraints never reject a delete.
|
||||
ordered = [
|
||||
"calendar_events", "calendar_sources", "reminders", "calendar_groups",
|
||||
"weather_cache", "weather_hourly_cache", "weather_minutely_cache",
|
||||
"weather_warning_cache", "weather_config", "clock_config",
|
||||
"alarms", "countdowns", "display_config", "pomodoro_state", "auth_config",
|
||||
"auth_tokens",
|
||||
]
|
||||
try:
|
||||
for table in ordered:
|
||||
for table in _DELETE_ORDER:
|
||||
conn.execute(f"DELETE FROM {table} WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||
conn.commit()
|
||||
@@ -826,6 +827,14 @@ def find_user_by_credential_id(credential_id: str, db_path: str = None) -> int |
|
||||
|
||||
# ─── DB init / migration ─────────────────────
|
||||
|
||||
# Stores passwords that were just generated, for the startup script to print.
|
||||
_EMITTED_PASSWORDS: list = []
|
||||
|
||||
# Current schema version supported by this program. Bump it whenever the
|
||||
# SCHEMA changes so existing databases get upgraded on next start.
|
||||
DB_VERSION = 2
|
||||
|
||||
|
||||
def _ensure_defaults(conn):
|
||||
"""Insert default rows for config tables and the Root user when missing.
|
||||
Generates a random 6-digit temp password when Root's auth_config is created
|
||||
@@ -843,6 +852,11 @@ def _ensure_defaults(conn):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, remark, role, status) VALUES (0, 'Root', 'admin', 'active')"
|
||||
)
|
||||
# Keep the users identity sequence ahead of explicit id=0 insertion.
|
||||
conn.execute(
|
||||
"SELECT setval(pg_get_serial_sequence('users', 'id'), "
|
||||
"GREATEST((SELECT COALESCE(MAX(id), 1) FROM users), 1))"
|
||||
)
|
||||
|
||||
# weather_config for Root
|
||||
count = conn.execute("SELECT COUNT(*) FROM weather_config WHERE user_id=0").fetchone()[0]
|
||||
@@ -895,165 +909,224 @@ def _ensure_defaults(conn):
|
||||
conn.commit()
|
||||
|
||||
|
||||
# Stores passwords that were just generated, for the startup script to print.
|
||||
_EMITTED_PASSWORDS: list = []
|
||||
|
||||
# Current schema version supported by this program. Bump it whenever the
|
||||
# SCHEMA changes so existing databases get rebuilt+migrated on next start.
|
||||
DB_VERSION = 1
|
||||
|
||||
|
||||
def _table_columns(conn, table_name):
|
||||
return [row["name"] for row in conn.execute(f"PRAGMA table_info({table_name})")]
|
||||
|
||||
|
||||
def _migrate_auth_config(conn_new, conn_old):
|
||||
"""Special-case migration for auth_config: the legacy table is keyed by
|
||||
`id` (single user, id=1), the new table is keyed by `user_id`.
|
||||
The legacy single-user row belongs to Root (user 0).
|
||||
"""
|
||||
old_cols = _table_columns(conn_old, "auth_config")
|
||||
new_cols = _table_columns(conn_new, "auth_config")
|
||||
if not old_cols:
|
||||
return
|
||||
rows = conn_old.execute("SELECT * FROM auth_config").fetchall()
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
if "user_id" in old_cols:
|
||||
uid = d.get("user_id") or 0
|
||||
else:
|
||||
# Legacy single-user DB: the id=1 row belongs to Root (user 0).
|
||||
old_id = d.get("id", 1)
|
||||
uid = 0 if old_id == 1 else (old_id or 0)
|
||||
common = [c for c in new_cols if c in old_cols and c not in ("id", "user_id")]
|
||||
if not common:
|
||||
continue
|
||||
col_str = ", ".join(common)
|
||||
placeholders = ", ".join("?" * len(common))
|
||||
conn_new.execute(
|
||||
f"INSERT OR IGNORE INTO auth_config (user_id, {col_str}) VALUES (?, {placeholders})",
|
||||
(uid, *[d[c] for c in common])
|
||||
)
|
||||
|
||||
|
||||
def init_db(db_path: str = "data.db"):
|
||||
"""Initialize the database with schema-version aware migration.
|
||||
|
||||
- No database file: create it from the latest SCHEMA and create Root.
|
||||
- Existing database:
|
||||
* no `meta.version` (legacy DB) → rebuild + migrate compatible data
|
||||
* DB version < DB_VERSION → rebuild + migrate
|
||||
* DB version == DB_VERSION → use as-is (fast path)
|
||||
* DB version > DB_VERSION → abort with an error (DB is newer
|
||||
than this program)
|
||||
|
||||
Rebuild copies compatible data (legacy single-user data becomes Root's)
|
||||
and fills missing columns with SCHEMA defaults.
|
||||
"""
|
||||
if not os.path.exists(db_path):
|
||||
conn = get_db(db_path)
|
||||
conn.executescript(SCHEMA)
|
||||
_ensure_defaults(conn)
|
||||
conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
conn_probe = get_db(db_path)
|
||||
old_version = _read_db_version(conn_probe)
|
||||
conn_probe.close()
|
||||
|
||||
if old_version is not None and old_version > DB_VERSION:
|
||||
raise SystemExit(
|
||||
f"[FATAL] 数据库 schema 版本 (v{old_version}) 高于当前程序支持的版本 "
|
||||
f"(v{DB_VERSION})。请先升级程序,或删除/降级数据库后再启动。"
|
||||
)
|
||||
|
||||
if old_version is not None and old_version == DB_VERSION:
|
||||
# Same version — no rebuild needed. Just ensure defaults exist.
|
||||
conn = get_db(db_path)
|
||||
_ensure_defaults(conn)
|
||||
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Migrate by rebuilding: create a fresh DB with the latest schema,
|
||||
# copy compatible data from the old DB, then replace the old DB.
|
||||
new_db_path = db_path + ".new"
|
||||
backup_path = db_path + ".backup"
|
||||
|
||||
# Clean up any stale temporary database from a previous failed run.
|
||||
if os.path.exists(new_db_path):
|
||||
os.remove(new_db_path)
|
||||
|
||||
conn_new = get_db(new_db_path)
|
||||
conn_new.executescript(SCHEMA)
|
||||
|
||||
conn_old = get_db(db_path)
|
||||
# Disable FK enforcement while copying; rows are copied table by table.
|
||||
conn_old.execute("PRAGMA foreign_keys=OFF")
|
||||
conn_new.execute("PRAGMA foreign_keys=OFF")
|
||||
|
||||
tables = conn_old.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
def _table_columns(conn, table_name: str) -> list:
|
||||
"""Return the column names of a table (information_schema)."""
|
||||
rows = conn.execute(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = current_schema() AND table_name = ? "
|
||||
"ORDER BY ordinal_position",
|
||||
(table_name,)
|
||||
).fetchall()
|
||||
|
||||
for (table_name,) in tables:
|
||||
# Tables that no longer exist in the new schema are dropped (e.g. camera_config).
|
||||
exists_new = conn_new.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(table_name,)
|
||||
).fetchone()
|
||||
if not exists_new:
|
||||
continue
|
||||
|
||||
if table_name == "auth_config":
|
||||
_migrate_auth_config(conn_new, conn_old)
|
||||
continue
|
||||
|
||||
old_cols = _table_columns(conn_old, table_name)
|
||||
new_cols = _table_columns(conn_new, table_name)
|
||||
common_cols = [c for c in new_cols if c in old_cols]
|
||||
if not common_cols:
|
||||
continue
|
||||
|
||||
col_str = ", ".join(common_cols)
|
||||
placeholders = ", ".join("?" * len(common_cols))
|
||||
sql = f"INSERT OR IGNORE INTO {table_name} ({col_str}) VALUES ({placeholders})"
|
||||
|
||||
for row in conn_old.execute(f"SELECT {col_str} FROM {table_name}"):
|
||||
try:
|
||||
conn_new.execute(sql, tuple(row))
|
||||
except Exception:
|
||||
# Ignore rows that cannot be migrated (e.g. constraint violations).
|
||||
pass
|
||||
|
||||
conn_new.commit()
|
||||
|
||||
# Now fill defaults for any tables that are still empty + Root bootstrap.
|
||||
_ensure_defaults(conn_new)
|
||||
|
||||
# Stamp the migrated database with the program's schema version.
|
||||
conn_new.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
||||
conn_new.commit()
|
||||
|
||||
conn_old.close()
|
||||
conn_new.close()
|
||||
|
||||
# Replace old DB with the new one, keeping a backup.
|
||||
if os.path.exists(backup_path):
|
||||
os.remove(backup_path)
|
||||
os.rename(db_path, backup_path)
|
||||
os.rename(new_db_path, db_path)
|
||||
return [r["column_name"] for r in rows]
|
||||
|
||||
|
||||
def _read_db_version(conn) -> int | None:
|
||||
"""Read the schema version from the `meta` table.
|
||||
Returns None when the DB is legacy (no meta table / no version column).
|
||||
Returns None when the DB is legacy (no meta table / no version row).
|
||||
"""
|
||||
try:
|
||||
row = conn.execute("SELECT version FROM meta WHERE id=1").fetchone()
|
||||
return int(row["version"]) if row else None
|
||||
except Exception:
|
||||
conn._safe_rollback()
|
||||
return None
|
||||
|
||||
|
||||
# ─── SCHEMA parsing for column-level migration ──────
|
||||
_CREATE_TABLE_RE = re.compile(
|
||||
r"CREATE TABLE IF NOT EXISTS\s+(\w+)\s*\((.*?)\)\s*;",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _split_top_level(text: str) -> list:
|
||||
"""Split `text` on top-level commas (ignoring commas inside parentheses
|
||||
AND inside single-quoted strings, e.g. DEFAULT '1,2,3,4,5,6,7')."""
|
||||
parts = []
|
||||
depth = 0
|
||||
cur = []
|
||||
in_str = False
|
||||
i = 0
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if ch == "'":
|
||||
# SQL 单引号转义用两个单引号表示
|
||||
if in_str and i + 1 < len(text) and text[i + 1] == "'":
|
||||
cur.append("''")
|
||||
i += 2
|
||||
continue
|
||||
in_str = not in_str
|
||||
cur.append(ch)
|
||||
elif not in_str:
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if ch == "," and depth == 0:
|
||||
parts.append("".join(cur))
|
||||
cur = []
|
||||
i += 1
|
||||
continue
|
||||
cur.append(ch)
|
||||
else:
|
||||
cur.append(ch)
|
||||
i += 1
|
||||
if cur:
|
||||
parts.append("".join(cur))
|
||||
return parts
|
||||
|
||||
|
||||
def _strip_check(s: str) -> str:
|
||||
"""Remove every CHECK(...) clause (parenthesis-balanced)."""
|
||||
out = s
|
||||
while True:
|
||||
idx = out.upper().find("CHECK(")
|
||||
if idx == -1:
|
||||
break
|
||||
depth = 0
|
||||
end = -1
|
||||
for j in range(idx, len(out)):
|
||||
if out[j] == "(":
|
||||
depth += 1
|
||||
elif out[j] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = j
|
||||
break
|
||||
if end == -1:
|
||||
break
|
||||
out = out[:idx] + out[end + 1:]
|
||||
return out
|
||||
|
||||
|
||||
def _migrate_missing_columns(conn):
|
||||
"""Add columns that exist in SCHEMA but are missing from existing tables.
|
||||
New columns are created with their SCHEMA DEFAULT (migration contract).
|
||||
Best-effort: a failing column does not block startup.
|
||||
"""
|
||||
for m in _CREATE_TABLE_RE.finditer(SCHEMA):
|
||||
table = m.group(1)
|
||||
try:
|
||||
existing = set(_table_columns(conn, table))
|
||||
except Exception:
|
||||
conn._safe_rollback()
|
||||
continue
|
||||
for col_def in _split_top_level(m.group(2)):
|
||||
col_def = col_def.strip()
|
||||
if not col_def:
|
||||
continue
|
||||
upper = col_def.upper()
|
||||
if upper.startswith(("PRIMARY KEY", "UNIQUE", "CHECK", "FOREIGN KEY", "CONSTRAINT")):
|
||||
continue
|
||||
name = col_def.split()[0].strip('"')
|
||||
if not name or name in existing:
|
||||
continue
|
||||
# Make the ADD COLUMN safe for rows that already exist:
|
||||
# drop NOT NULL / PK / UNIQUE / IDENTITY; keep TYPE + DEFAULT.
|
||||
safe = re.sub(r"\s+NOT\s+NULL\b", "", col_def, flags=re.IGNORECASE)
|
||||
safe = re.sub(r"\s+PRIMARY\s+KEY\b", "", safe, flags=re.IGNORECASE)
|
||||
safe = re.sub(r"\s+UNIQUE\b", "", safe, flags=re.IGNORECASE)
|
||||
safe = re.sub(r"\s+GENERATED\s+BY\s+DEFAULT\s+AS\s+IDENTITY\b", "", safe, flags=re.IGNORECASE)
|
||||
safe = _strip_check(safe).strip().rstrip(",")
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {safe}")
|
||||
except Exception:
|
||||
# rollback done by the shim; the next column starts a fresh tx
|
||||
pass
|
||||
|
||||
|
||||
def _fix_identity_sequences(conn):
|
||||
"""Sync every IDENTITY sequence to max(id) (safe after explicit id inserts)."""
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT table_name, column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = current_schema() AND is_identity = 'YES'"
|
||||
).fetchall()
|
||||
except Exception:
|
||||
conn._safe_rollback()
|
||||
return
|
||||
for r in rows:
|
||||
t, c = r["table_name"], r["column_name"]
|
||||
try:
|
||||
conn.execute(
|
||||
f"SELECT setval(pg_get_serial_sequence('{t}', '{c}'), "
|
||||
f"GREATEST((SELECT COALESCE(MAX({c}), 1) FROM {t}), 1))"
|
||||
)
|
||||
except Exception:
|
||||
conn._safe_rollback()
|
||||
|
||||
|
||||
# Timestamp columns whose DEFAULT was generated by early builds with the wrong
|
||||
# to_char pattern ('YYYY-MM-DD HH:MM:SS' — HH is 12-hour, MM is the month).
|
||||
_TIMESTAMP_DEFAULT_FIX = [
|
||||
("auth_tokens", "created_at"),
|
||||
("users", "created_at"),
|
||||
("calendar_groups", "created_at"),
|
||||
("weather_cache", "updated_at"),
|
||||
("weather_hourly_cache", "fetched_at"),
|
||||
]
|
||||
|
||||
|
||||
def _fix_timestamp_defaults(conn):
|
||||
"""Repair legacy DEFAULT expressions (idempotent, best-effort)."""
|
||||
for t, c in _TIMESTAMP_DEFAULT_FIX:
|
||||
try:
|
||||
conn.execute(
|
||||
f"ALTER TABLE {t} ALTER COLUMN {c} "
|
||||
"SET DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS')"
|
||||
)
|
||||
except Exception:
|
||||
conn._safe_rollback()
|
||||
|
||||
|
||||
def init_db(db_path: str = "data.db"):
|
||||
"""Initialize the database with schema-version aware migration (PostgreSQL).
|
||||
|
||||
- Fresh database (no `meta` row) -> create the latest SCHEMA + defaults.
|
||||
- Existing database:
|
||||
* DB version == DB_VERSION -> use as-is (fast path, defaults ensured)
|
||||
* no version / DB version < DB_VERSION
|
||||
-> create missing tables/columns; missing
|
||||
columns are filled with their SCHEMA
|
||||
DEFAULT; stamp the new version
|
||||
* DB version > DB_VERSION -> abort with an error (DB is newer than
|
||||
this program)
|
||||
"""
|
||||
conn = get_db(db_path)
|
||||
try:
|
||||
old_version = _read_db_version(conn)
|
||||
|
||||
if old_version is not None and old_version > DB_VERSION:
|
||||
raise SystemExit(
|
||||
f"[FATAL] 数据库 schema 版本 (v{old_version}) 高于当前程序支持的版本 "
|
||||
f"(v{DB_VERSION})。请先升级程序,或删除/降级数据库后再启动。"
|
||||
)
|
||||
|
||||
if old_version is not None and old_version == DB_VERSION:
|
||||
# Same version — no upgrade needed. Just ensure defaults exist.
|
||||
_ensure_defaults(conn)
|
||||
_fix_timestamp_defaults(conn)
|
||||
conn.commit()
|
||||
return
|
||||
|
||||
# Create any missing tables (CREATE TABLE IF NOT EXISTS is idempotent),
|
||||
# then add any columns missing from older databases. The DDL is
|
||||
# committed first so a later best-effort column migration (which may
|
||||
# roll back its own transaction) can never undo the schema creation.
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
_migrate_missing_columns(conn)
|
||||
|
||||
# Bootstrap defaults (Root user, meta row, config rows).
|
||||
_ensure_defaults(conn)
|
||||
|
||||
# Repair legacy timestamp DEFAULT expressions (old to_char HH:MM pattern).
|
||||
_fix_timestamp_defaults(conn)
|
||||
|
||||
# Stamp the database with the program's schema version.
|
||||
conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
||||
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
|
||||
_fix_identity_sequences(conn)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user