MEGAINFRA-WEBAPP/scripts/migrations/2026-09-12-rename-collections.py
DEls 0cdf385df2 refactor(schema): rename received_items and timesheets to their real names
RECEIVED_ITEMS -> DELIVERY_NOTE_ITEMS (55 rows): they are the lines on a
delivery note; the old name described an event, not a thing.

TIMESHEETS -> TIMESHEET_ENTRIES (1367 rows): this collection is the time
log, not a timesheet. The name is freed for the uploaded-document
collection created in Task 6.

PocketBase stores relations by collection id, so every relation survived
untouched; row counts asserted intact on both renames.

Two call sites the plan missed: src/app/api/timesheets/import/route.ts
and src/lib/ocr-result-tier.ts. Deliberately preserved the "timesheets"
string literals in pb-types.ts and the import route — those are
data_import_batches.batch_type select values, not collection names, and
changing them would have violated the select constraint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:01:40 +02:00

182 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""
Rename two collections as part of the ingestion restructure.
PB_URL=... PB_ADMIN_EMAIL=... PB_ADMIN_PASSWORD=... \\
python3 scripts/migrations/2026-09-12-rename-collections.py [--apply]
DRY RUN BY DEFAULT. Pass --apply to rename.
RECEIVED_ITEMS -> DELIVERY_NOTE_ITEMS
They are the lines on a delivery note. The old name describes the
event, not the thing.
TIMESHEETS -> TIMESHEET_ENTRIES
This collection is the time log (employee, date, hours, site), not
a timesheet. The name is freed for the uploaded-document
collection created in Task 6.
WHY THIS IS SAFE
----------------
PocketBase relations store collectionId, not name, so every existing
relation survives a rename untouched. Collection names are
case-insensitive, so lowercase call sites in `src/` keep resolving until
the code is updated - the rename and the code change do not have to be
simultaneous.
THE SAFETY PROPERTY
-------------------
A rename must move a name and nothing else. So each rename asserts, on
the collection id it just patched, that the new name stuck AND that the
row count is byte-identical to the count read immediately before. A
count that moves means something other than a rename happened, and the
run stops there rather than carrying on to the second collection with
an unexplained delta behind it.
Refuses to rename onto a name that is already taken by a different
collection. Idempotent: a collection already carrying the new name is
reported and skipped.
"""
import json
import os
import sys
import urllib.error
import urllib.request
APPLY = "--apply" in sys.argv
RENAMES = [
("received_items", "DELIVERY_NOTE_ITEMS"),
("timesheets", "TIMESHEET_ENTRIES"),
]
def admin_auth(base_url):
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 api(base_url, token, method, path, body=None):
"""
A 404 is only soft for GET - "this collection is absent" is a
legitimate answer to a read, and is how we detect an already-renamed
collection. On a write it is not: a PATCH against a since-renamed
collection returning None would be counted as a successful rename.
Mutations raise.
"""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{base_url}{path}",
data=data,
method=method,
headers={"Authorization": token, "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req) as resp:
txt = resp.read().decode()
return json.loads(txt) if txt else None
except urllib.error.HTTPError as e:
if e.code == 404 and method == "GET":
return None
raise RuntimeError(
f"{method} {path}: {e.code} {e.read().decode() if e.fp else ''}"
) from e
def row_count(base_url, token, coll_ref):
"""Total rows, read by collection id so it is stable across a rename."""
return api(
base_url,
token,
"GET",
f"/api/collections/{coll_ref}/records?perPage=1&fields=id",
)["totalItems"]
def main():
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
token = admin_auth(base_url)
# Say where we are writing. This script mutates production by default
# target, and a run whose output names no host is a run you cannot
# audit afterwards.
print(f"target: {base_url} mode: {'APPLY' if APPLY else 'DRY RUN'}")
all_colls = api(base_url, token, "GET", "/api/collections?perPage=200")
names_lower = {c["name"].lower(): c["name"] for c in all_colls["items"]}
renamed, skipped = 0, 0
for old, new in RENAMES:
cur = api(base_url, token, "GET", f"/api/collections/{old}")
if cur is None:
print(f"\nSKIP {old}: not found (already renamed?)")
skipped += 1
continue
if cur["name"].lower() == new.lower():
print(f"\nSKIP {old}: already named {cur['name']}")
skipped += 1
continue
# A name collision is not a skip. Renaming onto a live collection
# is not something this script can do safely, and continuing to
# the next rename would leave the restructure half-applied.
taken = names_lower.get(new.lower())
if taken is not None and taken.lower() != cur["name"].lower():
print(f"\nABORT {old} -> {new}: target name already taken")
return 1
count = row_count(base_url, token, old)
print(f"\n{cur['name']} -> {new} (id={cur['id']}, {count} rows)")
if not APPLY:
continue
api(base_url, token, "PATCH", f"/api/collections/{cur['id']}", {"name": new})
# Re-read by id, not by either name: reading by the new name
# would prove only that *a* collection has that name, not that
# this one does.
after = api(base_url, token, "GET", f"/api/collections/{cur['id']}")
if after is None:
print(f" FAILED: collection id={cur['id']} vanished after rename")
return 1
if after["name"] != new:
print(f" FAILED: rename did not stick: {after['name']!r} != {new!r}")
return 1
after_count = row_count(base_url, token, cur["id"])
if after_count != count:
print(
f" FAILED: row count changed during rename: "
f"{count} -> {after_count}. Stopping before the next rename."
)
return 1
print(f" OK, {after_count} rows intact")
renamed += 1
if not APPLY:
print("\nDRY RUN. Re-run with --apply to rename.")
return 0
print(f"\nrenamed {renamed} skipped {skipped} of {len(RENAMES)}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyError as e:
print(f"ERROR: missing environment variable {e}", file=sys.stderr)
sys.exit(2)