MEGAINFRA-WEBAPP/scripts/migrations/2026-08-29-fixed-costs-recurring-backfill.py
DEls 0290ced51a fix(schema): ensure is_recurring=false writes are preserved
The live PB fixed_costs collection never received the is_recurring field
added in 87bb179 — the bootstrap script is collection-create-only and
errors out on already-existing collections, and no migration added the
field. Smoke-test rows sent is_recurring=false and got it silently
dropped (PB ignores unknown fields on write), then the partition helper
treated the missing value as monthly (its !=== false fallback).

Two changes:

1. Drop 'default: true' from the bootstrap schema definition. A bool
   default only applies when the field is absent from the write payload,
   so this is belt-and-braces — but it removes any doubt that a default
   could ever silently override an explicit false. The partition helper
   still treats absent values as monthly, and the backfill migration
   patches pre-existing rows to true.

2. Make the backfill migration self-contained: if the field is missing
   from the collection, add it (bool, required=false, no default) before
   patching rows. Idempotent — no-op if already present.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 13:43:38 +02:00

191 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""
One-shot PocketBase migration applied via:
PB_URL=https://pb.mega.infra.apphub.co.za \\
PB_ADMIN_EMAIL=admin@mega.co.za \\
PB_ADMIN_PASSWORD=... \\
python3 scripts/migrations/2026-08-29-fixed-costs-recurring-backfill.py
Backfills `is_recurring = true` for all rows in FIXED_COSTS.
The `is_recurring` field was added in commit 87bb179 so the dashboard
can partition fixed costs into "monthly" vs "one-off" buckets. The
field has no `default` in the bootstrap schema: an explicit
`is_recurring: false` write must round-trip as `false`, and the
partition helper treats absent values as monthly via a `=== false`
check. Existing rows pre-date the field; backfilling them to `true`
matches design intent (every pre-existing row is monthly).
If the field has not yet been added to the live collection, this
script adds it first (idempotent: no-op if already present) before
backfilling row values.
Idempotent: re-running is safe; sets the same value.
"""
import json
import os
import sys
import urllib.error
import urllib.request
COLLECTION = "fixed_costs"
FIELD_NAME = "is_recurring"
def admin_auth(base_url: str) -> str:
email = os.environ["PB_ADMIN_EMAIL"]
password = os.environ["PB_ADMIN_PASSWORD"]
body = json.dumps({"identity": email, "password": password}).encode()
req = urllib.request.Request(
f"{base_url}/api/collections/_superusers/auth-with-password",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())["token"]
def get_collection(base_url: str, token: str, name: str):
req = urllib.request.Request(
f"{base_url}/api/collections/{name}",
headers={"Authorization": token},
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
def patch_collection(base_url: str, token: str, collection: dict):
"""PUT-style update of a collection (PB requires the full body to
update fields)."""
req = urllib.request.Request(
f"{base_url}/api/collections/{collection['id']}",
data=json.dumps(collection).encode(),
headers={
"Authorization": token,
"Content-Type": "application/json",
},
method="PATCH",
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body_txt = e.read().decode() if e.fp else ""
raise RuntimeError(
f"PATCH collection {collection['id']} failed: {e.code} {body_txt}"
) from e
def ensure_field(base_url: str, token: str, collection: dict, field_name: str):
"""If `field_name` is missing from the collection, append it as a
bool with no default (matches pb-bootstrap-schema.py) and PATCH.
Idempotent: returns the (possibly unchanged) updated collection."""
fields = collection.get("fields", [])
if any(f["name"] == field_name for f in fields):
return collection
fields.append({
"name": field_name,
"type": "bool",
"required": False,
"presentable": False,
"hidden": False,
"system": False,
})
collection["fields"] = fields
return patch_collection(base_url, token, collection)
def list_records(base_url: str, token: str, name: str) -> list:
"""Fetch all records from a collection, paginating through results."""
records = []
page = 1
per_page = 500
while True:
req = urllib.request.Request(
f"{base_url}/api/collections/{name}/records?page={page}&perPage={per_page}",
headers={"Authorization": token},
)
with urllib.request.urlopen(req) as resp:
payload = json.loads(resp.read())
records.extend(payload.get("items", []))
total_pages = payload.get("totalPages", 1)
if page >= total_pages:
break
page += 1
return records
def patch_record(base_url: str, token: str, name: str, record_id: str, body: dict):
req = urllib.request.Request(
f"{base_url}/api/collections/{name}/records/{record_id}",
data=json.dumps(body).encode(),
headers={
"Authorization": token,
"Content-Type": "application/json",
},
method="PATCH",
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body_txt = e.read().decode() if e.fp else ""
raise RuntimeError(
f"PATCH {name}/{record_id} failed: {e.code} {body_txt}"
) from e
def main() -> int:
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
token = admin_auth(base_url)
coll = get_collection(base_url, token, COLLECTION)
if not coll:
print(f"ERROR: collection {COLLECTION!r} not found on {base_url}")
return 1
field_names = {f["name"] for f in coll.get("fields", [])}
if FIELD_NAME not in field_names:
print(
f"Collection {COLLECTION!r} has no field {FIELD_NAME!r}. "
f"Adding it now (bool, required=false, no default)."
)
coll = ensure_field(base_url, token, coll, FIELD_NAME)
field_names = {f["name"] for f in coll.get("fields", [])}
records = list_records(base_url, token, COLLECTION)
print(f"Found {len(records)} record(s) in {COLLECTION}.")
updated = 0
for record in records:
if record.get(FIELD_NAME) is True:
continue
patch_record(base_url, token, COLLECTION, record["id"], {FIELD_NAME: True})
updated += 1
print(f"Updated {updated} record(s) to {FIELD_NAME}=true.")
if updated == 0:
print("All records already had is_recurring=true (no-op).")
print("\nMigration complete.")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyError as e:
print(
f"ERROR: missing environment variable {e}. "
f"Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.",
file=sys.stderr,
)
sys.exit(2)