#!/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-09-11-add-document-fields.py Phase 1 of the ingestion restructure (see docs/superpowers/specs/2026-09-11-ingestion-restructure-design.md). Strictly additive. Adds: delivery_notes.file_hash - SHA-256, so DN dedup can stop depending on data_import_batches purchase_orders.processing_status / attempts / last_error / next_retry_at / last_attempt_at - the queue fields that currently live on the batch row, so the PO row can BE the queue item Nothing is made required here: existing rows have no values for these and PocketBase rejects a required field that existing rows violate. Tightening happens after the routes are writing the fields (Phase 3). Idempotent: re-running skips fields that already exist. """ import json import os import sys import urllib.error import urllib.request ADDITIONS = { "delivery_notes": [ {"name": "file_hash", "type": "text", "required": False, "max": 64}, ], "purchase_orders": [ # The PO row owns its file from upload onward (Task 13), so it # owns the hash too — the dedup query has no pool to consult. {"name": "file_hash", "type": "text", "required": False, "max": 64}, { "name": "processing_status", "type": "select", "required": False, "maxSelect": 1, "values": [ "pending", "processing", "processed", "failed", "dead", ], }, {"name": "attempts", "type": "number", "required": False}, {"name": "last_error", "type": "text", "required": False}, {"name": "next_retry_at", "type": "date", "required": False}, {"name": "last_attempt_at", "type": "date", "required": False}, ], } def admin_auth(base_url: str) -> str: body = json.dumps( { "identity": os.environ["PB_ADMIN_EMAIL"], "password": os.environ["PB_ADMIN_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, name: str, body: dict): req = urllib.request.Request( f"{base_url}/api/collections/{name}", 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: raise RuntimeError( f"PATCH {name} failed: {e.code} {e.read().decode() if e.fp else ''}" ) from e def main() -> int: base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/") token = admin_auth(base_url) for coll_name, new_fields in ADDITIONS.items(): coll = get_collection(base_url, token, coll_name) if not coll: print(f"ERROR: collection {coll_name!r} not found on {base_url}") return 1 existing = coll.get("fields", []) have = {f["name"] for f in existing} to_add = [f for f in new_fields if f["name"] not in have] if not to_add: print(f"{coll_name}: no-op, all fields already present") continue print(f"{coll_name}: adding {[f['name'] for f in to_add]}") patch_collection(base_url, token, coll_name, {"fields": existing + to_add}) print("\nFinal field lists:") for coll_name in ADDITIONS: final = get_collection(base_url, token, coll_name) print(f" {coll_name}: {sorted(f['name'] for f in final.get('fields', []))}") 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)