From b1d06c1aa71f384a7a8febf1b7639655bcaa2fe9 Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 11 Sep 2026 10:29:10 +0200 Subject: [PATCH] feat(schema): add file_hash and queue fields to DNs and POs --- .../2026-09-11-add-document-fields.py | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100755 scripts/migrations/2026-09-11-add-document-fields.py diff --git a/scripts/migrations/2026-09-11-add-document-fields.py b/scripts/migrations/2026-09-11-add-document-fields.py new file mode 100755 index 0000000..ccd561f --- /dev/null +++ b/scripts/migrations/2026-09-11-add-document-fields.py @@ -0,0 +1,147 @@ +#!/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)