forked from sass/tipibot
Sync pb
This commit is contained in:
@@ -70,7 +70,7 @@ The economy system stores all player data in [PocketBase](https://pocketbase.io/
|
|||||||
1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root.
|
1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root.
|
||||||
2. Start PocketBase: `.\pocketbase.exe serve`
|
2. Start PocketBase: `.\pocketbase.exe serve`
|
||||||
3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account.
|
3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account.
|
||||||
4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes.
|
4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes, then run `python scripts/sync_pb_schema.py` to add every field the bot persists. Re-run it after any update that introduces new fields (PocketBase silently drops writes to fields missing from the schema).
|
||||||
5. Set `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` in `.env`.
|
5. Set `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` in `.env`.
|
||||||
6. **One-time data migration** (only if you have an existing `data/economy.json`): `python scripts/migrate_to_pb.py`
|
6. **One-time data migration** (only if you have an existing `data/economy.json`): `python scripts/migrate_to_pb.py`
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ Spend PP in `/prestigeshop`:
|
|||||||
- The daily set resets at **UTC midnight**, the weekly set on the **ISO week** rollover. Each player gets their own rotation.
|
- The daily set resets at **UTC midnight**, the weekly set on the **ISO week** rollover. Each player gets their own rotation.
|
||||||
- Rewards are TipiCOINs (prestige coin multiplier applies) plus EXP through the normal level-up path.
|
- Rewards are TipiCOINs (prestige coin multiplier applies) plus EXP through the normal level-up path.
|
||||||
|
|
||||||
> Schema note: the quest fields live in PocketBase - after pulling this feature run `python scripts/add_quest_fields.py` once (it patches both the dev and prod collections).
|
> Schema note: the quest fields live in PocketBase - after pulling this feature run `python scripts/sync_pb_schema.py` once (it reconciles both the dev and prod collections with everything the bot persists).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -511,8 +511,9 @@ Every slash command invocation is logged with the user ID, display name, and all
|
|||||||
│ └── POCKETBASE_SETUP.md # PocketBase collection schema + setup instructions
|
│ └── POCKETBASE_SETUP.md # PocketBase collection schema + setup instructions
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── migrate_to_pb.py # One-time legacy migration: economy.json → PocketBase
|
│ ├── migrate_to_pb.py # One-time legacy migration: economy.json → PocketBase
|
||||||
│ ├── add_stats_fields.py # Schema migration: add stats fields to economy_users collection
|
│ ├── sync_pb_schema.py # Reconcile PB collections with the full user schema (dev + prod)
|
||||||
│ ├── add_quest_fields.py # Schema migration: add quest fields (patches dev + prod)
|
│ ├── add_stats_fields.py # Older partial schema migration (superseded by sync_pb_schema.py)
|
||||||
|
│ ├── add_quest_fields.py # Older partial schema migration (superseded by sync_pb_schema.py)
|
||||||
│ └── reset_pb_collections.py # Destructive: deletes & recreates economy collections (--confirm required)
|
│ └── reset_pb_collections.py # Destructive: deletes & recreates economy collections (--confirm required)
|
||||||
├── requirements.txt # Python dependencies
|
├── requirements.txt # Python dependencies
|
||||||
├── .env.example # Template for secrets
|
├── .env.example # Template for secrets
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ Add the following fields:
|
|||||||
|
|
||||||
> **Tip:** Set `user_id` as a unique index under **Indexes** tab.
|
> **Tip:** Set `user_id` as a unique index under **Indexes** tab.
|
||||||
|
|
||||||
|
The table above is only the base schema. After creating the collections, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/sync_pb_schema.py
|
||||||
|
```
|
||||||
|
|
||||||
|
It derives the complete field list from the bot's user model and adds whatever
|
||||||
|
is missing to **both** collections (stats, fishing, prestige, quests, ...).
|
||||||
|
Re-run it after every update - PocketBase silently drops writes to fields that
|
||||||
|
are not in the collection schema, which breaks features without any error.
|
||||||
|
|
||||||
Set **API rules** (all four: list, view, create, update) to admin-only (leave blank / locked).
|
Set **API rules** (all four: list, view, create, update) to admin-only (leave blank / locked).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
131
scripts/sync_pb_schema.py
Normal file
131
scripts/sync_pb_schema.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Reconcile the PocketBase economy collections with the bot's user schema.
|
||||||
|
|
||||||
|
The expected field list is derived from core.economy._default_user() - the
|
||||||
|
single source of truth for what the bot persists - so this supersedes
|
||||||
|
add_stats_fields.py / add_quest_fields.py and also covers the fields those
|
||||||
|
scripts never added (fishing, prestige, quests, last_heist, ...).
|
||||||
|
|
||||||
|
PocketBase silently drops record fields that are missing from the collection
|
||||||
|
schema, so any drift here breaks features without a single error message.
|
||||||
|
|
||||||
|
Patches BOTH profile collections regardless of BOT_PROFILE. Only ever adds
|
||||||
|
fields; existing fields are never removed or retyped (type mismatches are
|
||||||
|
reported for manual review).
|
||||||
|
|
||||||
|
python scripts/sync_pb_schema.py # add whatever is missing
|
||||||
|
python scripts/sync_pb_schema.py --check # report only; exit 1 on drift
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
import config # noqa: E402
|
||||||
|
from core import economy # noqa: E402
|
||||||
|
|
||||||
|
PB_URL = config.PB_URL
|
||||||
|
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
|
||||||
|
|
||||||
|
# _default_user() keys whose default is None, all ISO date/datetime strings
|
||||||
|
_TEXT_FIELDS = {
|
||||||
|
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
|
||||||
|
"last_heist", "last_fish", "last_streak_date", "jailed_until",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_fields() -> dict[str, str]:
|
||||||
|
"""Map every persisted field name to its PocketBase field type."""
|
||||||
|
expected = {"user_id": "text"}
|
||||||
|
for key, default in economy._default_user().items():
|
||||||
|
if key in _TEXT_FIELDS:
|
||||||
|
expected[key] = "text"
|
||||||
|
elif isinstance(default, bool):
|
||||||
|
expected[key] = "bool"
|
||||||
|
elif isinstance(default, (int, float)):
|
||||||
|
expected[key] = "number"
|
||||||
|
elif isinstance(default, (list, dict)):
|
||||||
|
expected[key] = "json"
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Cannot infer a PocketBase type for {key!r} (default {default!r}). "
|
||||||
|
"Add it to _TEXT_FIELDS or extend the mapping."
|
||||||
|
)
|
||||||
|
return expected
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
check_only = "--check" in sys.argv
|
||||||
|
expected = _expected_fields()
|
||||||
|
drift = False
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=15)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.post(
|
||||||
|
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
||||||
|
json={"identity": config.PB_ADMIN_EMAIL, "password": config.PB_ADMIN_PASSWORD},
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
print(f"Auth failed ({resp.status}): {await resp.text()}")
|
||||||
|
return 1
|
||||||
|
hdrs = {"Authorization": (await resp.json())["token"]}
|
||||||
|
|
||||||
|
for collection in COLLECTIONS:
|
||||||
|
print(f"── {collection} ──")
|
||||||
|
async with session.get(
|
||||||
|
f"{PB_URL}/api/collections/{collection}", headers=hdrs
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
print(f"Could not fetch collection ({resp.status}): {await resp.text()}\n")
|
||||||
|
drift = True
|
||||||
|
continue
|
||||||
|
col = await resp.json()
|
||||||
|
|
||||||
|
existing = {f["name"]: f.get("type", "?") for f in col.get("fields", [])}
|
||||||
|
|
||||||
|
missing = [name for name in expected if name not in existing]
|
||||||
|
mismatched = [
|
||||||
|
(name, existing[name], ftype)
|
||||||
|
for name, ftype in expected.items()
|
||||||
|
if name in existing and existing[name] != ftype
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, have, want in mismatched:
|
||||||
|
drift = True
|
||||||
|
print(f" ! {name}: schema has type '{have}', bot expects '{want}' - fix manually")
|
||||||
|
|
||||||
|
if not missing:
|
||||||
|
print(" ✓ no missing fields\n" if not mismatched else "")
|
||||||
|
continue
|
||||||
|
|
||||||
|
drift = True
|
||||||
|
for name in missing:
|
||||||
|
print(f" + {name} ({expected[name]})")
|
||||||
|
|
||||||
|
if check_only:
|
||||||
|
print()
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_fields = [{"name": name, "type": expected[name]} for name in missing]
|
||||||
|
async with session.patch(
|
||||||
|
f"{PB_URL}/api/collections/{collection}",
|
||||||
|
json={"fields": col.get("fields", []) + new_fields},
|
||||||
|
headers=hdrs,
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
print(f" Schema update failed ({resp.status}): {await resp.text()}\n")
|
||||||
|
continue
|
||||||
|
print(f" ✅ added {len(new_fields)} field(s)\n")
|
||||||
|
|
||||||
|
return 1 if (check_only and drift) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(asyncio.run(main()))
|
||||||
Reference in New Issue
Block a user