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