From 8f280d4bd1e9fe18a6c33bb7cd2b5b3f78f04201 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Fri, 21 Aug 2026 12:05:27 -0700 Subject: [PATCH] D6 hotfix - the material_items migration crashed Postgres at deploy server_default=sa.text('1') on a Boolean: SQLite coerces integer 1, Postgres refuses it (DatatypeMismatch: column 'active' is of type boolean but default expression is of type integer) - so 'verified end-to-end on a scratch DB' was true and insufficient, because the scratch DB was SQLite. Found in production 2026-08-21: the wp.controls.dev api container crash-looped on alembic upgrade and the site served static pages with a 502 API until the table was created by hand from the db container (identical DDL, alembic_version stamped to a1b8c6d4e2f9, so this fixed migration is a no-op there). Now sa.true() - which the location-taxonomy migration next door used correctly all along, and which is why IT applied to production without incident. materials_check gains the static pin: every Boolean server_default in every migration must be sa.true()/sa.false(). Verified: alembic --sql offline render for the postgresql dialect emits DEFAULT true; the full chain still applies on a scratch SQLite. Items: D6 (the migration), CR-013 surface. Probe: materials_check +1 static check (its browser half was env-blocked today - headless browser would not start; the fix is exercised entirely by the static half and the two renders). Co-Authored-By: Claude Fable 5 --- .../a1b8c6d4e2f9_material_items_project_list.py | 6 +++++- tests/materials_check.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/server/alembic/versions/a1b8c6d4e2f9_material_items_project_list.py b/server/alembic/versions/a1b8c6d4e2f9_material_items_project_list.py index 4661c90..0fa5f06 100644 --- a/server/alembic/versions/a1b8c6d4e2f9_material_items_project_list.py +++ b/server/alembic/versions/a1b8c6d4e2f9_material_items_project_list.py @@ -32,7 +32,11 @@ def upgrade() -> None: sa.Column('code', sa.String(length=80), nullable=False, server_default=''), sa.Column('description', sa.String(length=300), nullable=False, server_default=''), sa.Column('unit', sa.String(length=20), nullable=False, server_default=''), - sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('1')), + # sa.true(), not sa.text('1'): SQLite coerces integer 1 to boolean, + # Postgres refuses it (DatatypeMismatch) - found when this migration + # took down the wp.controls.dev api container on 2026-08-21. The + # location-taxonomy migration next door had it right all along. + sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()), sa.Column('sort', sa.Integer(), nullable=False, server_default='0'), sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), sa.PrimaryKeyConstraint('id'), diff --git a/tests/materials_check.py b/tests/materials_check.py index c477d28..c0a1c89 100644 --- a/tests/materials_check.py +++ b/tests/materials_check.py @@ -54,6 +54,20 @@ def main(): chk("locations and materials are both instances of it - not a copy beside it", "locList = WPListImport(" in suite and "matList = WPListImport(" in suite and "function locImport(dryRun){ locList.importText" in suite) + # The 2026-08-21 outage, pinned: a Boolean server_default of sa.text('1') + # passes on SQLite (which coerces 1) and crash-loops Postgres at deploy + # (DatatypeMismatch). Every migration must say sa.true()/sa.false(). + import re as _re + bad = [] + vdir = os.path.join(ROOT, "server", "alembic", "versions") + for fn in sorted(os.listdir(vdir)): + if not fn.endswith(".py"): + continue + for ln in open(os.path.join(vdir, fn), encoding="utf-8"): + if "Boolean" in ln and "server_default" in ln and not _re.search(r"server_default=sa\.(true|false)\(\)", ln): + bad.append("%s: %s" % (fn, ln.strip()[:90])) + chk("no migration gives a Boolean a non-portable server_default " + "(sa.true()/sa.false() only)", not bad, ascii_(bad[:3])) model = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read() mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")] cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M)