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>
191 lines
6.2 KiB
Python
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)
|