0514568df0
The testing pass: 2140 tests to 2283, and four bugs that no amount of reading had turned up. Three came from driving the JavaScript under a Node DOM stub, which is the practice CLAUDE.md sets out and this is the reason it does. The terminal dropped every keystroke after a reconnect. `onclose` closed over the module-level socket rather than its own, and close() queues its event -- so the old socket's close arrived after a new one was assigned and nulled the live one. Output kept coming, because onmessage is bound to the object, while every send gates on the variable. It also announced "Disconnected" about a shell that had just reconnected. Two scripts were loaded twice on /messages, once by base.html and again by the page. Each is an IIFE with its own state, so four keyboard shortcuts toggled their panel twice and therefore did nothing, /help opened two dialogs, and an @ mention attached its file twice. A sweep refuses any template re-loading what base.html has. The microphone had no guard while the permission prompt was up, so each click opened another stream and only the last was ever stopped. And a skill shared with you took its name out of your own library: create checked uniqueness against what is *visible* rather than what is owned, against a (owner_id, name) constraint, and told you to edit a row you cannot edit. --ink-faint failed the contrast minimum in both themes -- 3.85 and 3.19 against 4.5 -- so the smallest text on every screen was the hardest to read. Measured in a headless browser rather than judged by eye. And the suite runs on 3.11 and 3.12 now as well as 3.14. It had only ever run on 3.14 while the image ships 3.12 and the packaging claimed 3.11: the interpreter most people would run was the one nothing had tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
8.9 KiB
Python
233 lines
8.9 KiB
Python
"""The upgrade path, which every existing install takes to reach 1.0.0.
|
|
|
|
There is no Alembic here by design -- hard rule 4, additive-only, with
|
|
`db/migrations.py:sync_schema` deriving the change from the models by diffing
|
|
them against the live database. That makes the differ the *only* thing standing
|
|
between an 0.8.x deployment and a broken one, and until this file it had no test
|
|
that exercised it as a migration at all.
|
|
|
|
`tests/conftest.py` runs `create_all` and *then* `sync_schema`, so the schema it
|
|
is handed is always already current: the differ finds nothing missing, does
|
|
nothing, and reports success. Every run of the suite proved that a no-op is a
|
|
no-op. The two unit tests that did exist covered `_add_column_sql`'s DDL string
|
|
and never touched a database.
|
|
|
|
So these build an **old-shaped database with rows in it** and upgrade it for
|
|
real. The shape is not invented: `OLD_TABLES` and `OLD_COLUMNS` are what
|
|
actually arrived between `0.8.1` and this release, taken from
|
|
`git diff 00ce04a..HEAD -- src/lembas/db/models/`. `test_the_recorded_shape_is_still_real`
|
|
is what stops that list rotting into a test that upgrades nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from sqlalchemy import inspect, text
|
|
|
|
from lembas.db.base import Base
|
|
from lembas.db.migrations import ensure_fts, sync_schema
|
|
from lembas.db.session import get_engine
|
|
|
|
# Tables that did not exist at 0.8.1. `sync_schema` has to create them.
|
|
OLD_TABLES = ("chunks", "push_subscriptions", "usage")
|
|
|
|
# Columns added to tables that already existed, and therefore already had rows.
|
|
# These are the interesting half: a new *table* is empty by definition, but a
|
|
# new *column* has to arrive beside data somebody cares about.
|
|
OLD_COLUMNS = (
|
|
("ssh_profiles", "resolves_here"),
|
|
("chats", "parent_chat_id"),
|
|
("chats", "unattended"),
|
|
("reports", "unread_notified"),
|
|
("groups", "limits_json"),
|
|
)
|
|
|
|
|
|
def _rollback_to_0_8_1(engine) -> None:
|
|
"""Take a current database back to the shape 0.8.1 left behind.
|
|
|
|
Backwards rather than forwards because the alternative is importing two
|
|
versions of the models into one interpreter, which is not a thing Python
|
|
will do -- `lembas.db.models` is already imported and registered on one
|
|
`Base.metadata`. Dropping what was added produces the same *shape* the
|
|
differ has to repair, which is what is under test.
|
|
"""
|
|
with engine.begin() as connection:
|
|
for table in OLD_TABLES:
|
|
connection.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
|
for table, column in OLD_COLUMNS:
|
|
connection.execute(text(f"ALTER TABLE {table} DROP COLUMN {column}"))
|
|
|
|
|
|
def _columns(engine, table: str) -> set[str]:
|
|
return {column["name"] for column in inspect(engine).get_columns(table)}
|
|
|
|
|
|
def test_the_recorded_shape_is_still_real():
|
|
"""The rollback above names tables and columns by hand, and a name that has
|
|
since been removed or renamed would make it silently upgrade nothing --
|
|
a test that passes because it tested an empty set.
|
|
"""
|
|
tables = Base.metadata.tables
|
|
|
|
for table in OLD_TABLES:
|
|
assert table in tables, f"{table} is no longer a table; fix OLD_TABLES"
|
|
for table, column in OLD_COLUMNS:
|
|
assert table in tables, table
|
|
assert column in tables[table].c, f"{table}.{column} is gone; fix OLD_COLUMNS"
|
|
|
|
|
|
def test_an_0_8_1_database_with_data_upgrades(db):
|
|
"""The whole point. Rows written before the upgrade must still be there
|
|
afterwards, with their values, and the new columns must exist beside them.
|
|
|
|
A failure here is somebody's instance not starting after pressing Update,
|
|
or -- worse and quieter -- starting with a column that silently reads NULL.
|
|
"""
|
|
engine = get_engine()
|
|
|
|
# Data first, while the schema still has the columns the ORM expects.
|
|
from lembas.db.models import Chat, Group, User
|
|
|
|
owner = User(name="Frodo", email="f@shire.test", password_hash="x") # noqa: S106
|
|
db.add(owner)
|
|
db.commit()
|
|
group = Group(name="Fellowship")
|
|
db.add(group)
|
|
db.commit()
|
|
chat = Chat(user_id=owner.id, model_id="mithril", title="A chat from before")
|
|
db.add(chat)
|
|
db.commit()
|
|
chat_id, owner_id, group_id = chat.id, owner.id, group.id
|
|
db.close()
|
|
|
|
_rollback_to_0_8_1(engine)
|
|
|
|
# Confirm the rollback actually removed things, or the assertions below
|
|
# would pass against a database that was never old.
|
|
assert "chunks" not in inspect(engine).get_table_names()
|
|
assert "unattended" not in _columns(engine, "chats")
|
|
|
|
changes = sync_schema(engine)
|
|
|
|
assert changes, "the differ reported nothing to do on an old database"
|
|
for table in OLD_TABLES:
|
|
assert table in inspect(engine).get_table_names(), table
|
|
for table, column in OLD_COLUMNS:
|
|
assert column in _columns(engine, table), f"{table}.{column}"
|
|
|
|
# And the rows are still what they were.
|
|
with engine.connect() as connection:
|
|
row = connection.execute(
|
|
text("SELECT title, model_id, user_id FROM chats WHERE id = :id"),
|
|
{"id": chat_id},
|
|
).one()
|
|
assert row.title == "A chat from before"
|
|
assert row.model_id == "mithril"
|
|
assert row.user_id == owner_id
|
|
assert (
|
|
connection.execute(
|
|
text("SELECT name FROM groups WHERE id = :id"), {"id": group_id}
|
|
).scalar()
|
|
== "Fellowship"
|
|
)
|
|
|
|
|
|
def test_a_nullable_column_arrives_empty_rather_than_defaulted(db):
|
|
"""A nullable column is added with **no** default, so an existing row reads
|
|
NULL -- the value the model treats as absent.
|
|
|
|
An earlier version defaulted every column by type, which meant an added
|
|
foreign key arrived as `""` on old rows, and every "is this set?" check
|
|
downstream was wrong about them. `parent_chat_id` is exactly that shape: a
|
|
chat that predates subagents is not a helper, and `""` would not say so.
|
|
"""
|
|
engine = get_engine()
|
|
|
|
from lembas.db.models import Chat, User
|
|
|
|
owner = User(name="Sam", email="s@shire.test", password_hash="x") # noqa: S106
|
|
db.add(owner)
|
|
db.commit()
|
|
chat = Chat(user_id=owner.id, model_id="m")
|
|
db.add(chat)
|
|
db.commit()
|
|
chat_id = chat.id
|
|
db.close()
|
|
|
|
_rollback_to_0_8_1(engine)
|
|
sync_schema(engine)
|
|
|
|
with engine.connect() as connection:
|
|
parent = connection.execute(
|
|
text("SELECT parent_chat_id FROM chats WHERE id = :id"), {"id": chat_id}
|
|
).scalar()
|
|
assert parent is None, "an added foreign key must be absent, not empty"
|
|
|
|
|
|
def test_a_not_null_column_backfills_every_existing_row(db):
|
|
"""SQLite refuses to add a NOT NULL column without a default, so one is
|
|
derived from the type. `chats.unattended` is a boolean: an old row has to
|
|
come back False rather than NULL, or every `if chat.unattended` downstream
|
|
reads a null as a value.
|
|
"""
|
|
engine = get_engine()
|
|
|
|
from lembas.db.models import Chat, User
|
|
|
|
owner = User(name="Merry", email="m@shire.test", password_hash="x") # noqa: S106
|
|
db.add(owner)
|
|
db.commit()
|
|
db.add(Chat(user_id=owner.id, model_id="m"))
|
|
db.commit()
|
|
db.close()
|
|
|
|
_rollback_to_0_8_1(engine)
|
|
sync_schema(engine)
|
|
|
|
with engine.connect() as connection:
|
|
values = connection.execute(text("SELECT unattended FROM chats")).scalars().all()
|
|
assert values, "no rows survived the upgrade"
|
|
assert all(value in (0, False) for value in values), values
|
|
|
|
|
|
def test_upgrading_twice_changes_nothing_the_second_time(db):
|
|
"""It runs on every startup and has to converge. A second pass reporting
|
|
work would mean it was re-adding something, which on SQLite is an error that
|
|
stops the application booting."""
|
|
engine = get_engine()
|
|
_rollback_to_0_8_1(engine)
|
|
|
|
assert sync_schema(engine)
|
|
assert sync_schema(engine) == []
|
|
|
|
|
|
def test_the_search_indexes_are_rebuilt_when_missing(db):
|
|
"""FTS5 tables are not SQLAlchemy models, so `create_all` cannot make them
|
|
and the column differ cannot see them. `ensure_fts` is what converges them,
|
|
and it runs at every startup for the same reason the column sync does --
|
|
an instance whose indexes were dropped must repair itself rather than
|
|
return nothing and call it an empty library.
|
|
"""
|
|
engine = get_engine()
|
|
with engine.begin() as connection:
|
|
connection.execute(text("DROP TABLE IF EXISTS documents_fts"))
|
|
|
|
assert "documents_fts" in ensure_fts(engine)
|
|
assert "documents_fts" in inspect(engine).get_table_names()
|
|
# Converges: a second call has nothing left to make.
|
|
assert ensure_fts(engine) == []
|
|
|
|
|
|
@pytest.mark.parametrize("table", OLD_TABLES)
|
|
def test_each_new_table_is_usable_after_the_upgrade(db, table):
|
|
"""Created is not the same as correct. A table the differ made but got the
|
|
columns wrong on fails at the first insert, which is the first *reply* on an
|
|
upgraded instance rather than at startup."""
|
|
engine = get_engine()
|
|
_rollback_to_0_8_1(engine)
|
|
sync_schema(engine)
|
|
|
|
declared = {column.name for column in Base.metadata.tables[table].c}
|
|
assert _columns(engine, table) == declared
|