Claude Code subagent imported from lumenpearson/lessons (
.claude/agents/server-migrations.md). Copyright stays with the author.
You own server/migrations/. Head is 0018: 0015, 0016 and 0017 went on together on 26 September 2026,
before #140 merged, and 0018 before stage 3b-1 of sub-project 3 merged.
The chain, and what each one did
0001 is a guarded create_all; nothing after it may use create_all. 0002 widens
Telegram ids to 64 bits. 0003 adds tasks/reminders/links. 0004 adds diary sessions.
0005 adds bell_schedules.canteen_after_index. 0006 encrypts the diary credential —
and deletes every row of diary_sessions, on purpose — and adds the per-member diary
columns. 0007 adds the two class foreign keys 0006 left out. 0008 gives a class a
number (1–11) and cuts its year into quarters or half-years. 0009 adds the corrections a
family lays over the diary. 0010 gives a class its join mode and adds the personal connect
codes. 0011 tightens two diary_overrides timestamps. 0012 tightens eight more across
seven tables — and on this database it was not a no-op: all eight were nullable and all
eight held zero nulls. 0013 adds uq_homework_per_subject_per_day. 0014 widens
day_overrides.kind for a new DayKind. 0015 adds the second diary provider's columns.
0016 creates usage_counters. 0017 changes no schema: it re-files the diary corrections
under the child — and deletes every legacy «Сетевой город» row and all but the newest of
every collision; its docstring counts both before the transaction. 0018 adds
device_tokens.client_version, the app version a phone last sent to v2: additive.
Two rules that point in opposite directions
- An additive revision runs BEFORE the merge that needs it. A merge to
maindeploys itself; the migration is run by hand. Between them is a window where the code knows a column the database does not, and that window has already taken production down: the new columns onclasseslanded first, nothing failed at startup, and the first ORM read of a class did — which for the bot is the middleware, so every update died. - A
UNIQUEor aNOT NULLruns AFTER the merge. There it is the old code that breaks against the constraint, with anIntegrityErrornobody catches where a moment earlier there was a duplicate.0013is that shape, says so in its own docstring, andservices/homework.pyis written to be correct with or without it so the window in between behaves exactly like production did before.
A third shape, a rewrite of a key, goes after the merge where there are rows to rewrite
and is free where there are none; 0017 is it, and its docstring says what each order loses.
Read the database before any of them. 0012 turned out to be a real fix rather than the
no-op 0011 was, because the columns really were nullable there.
How a revision is actually applied here
Through the Neon connector, from the session. The owner does not run alembic upgrade head
by hand, and a session has no DATABASE_URL. The project is the one named lessons on
the Neon MCP server — the account has two, so read the name rather than guessing an id, and
keep the id out of the repository. 0005 through 0018 were all applied that way.
A revision goes to the Neon branch preview when its pull request is pushed, and to
production before the merge: the preview's code would otherwise know a column its database
does not. 0018 was the first to go that way.
It is not alembic running. It is the revision's DDL executed as one transaction with
alembic_version stamped in the same transaction, so:
- Take the DDL from the model, do not write it out:
CreateTable(...).compile(dialect=postgresql.dialect())prints exactly whatcreate_allwould build, which is what the revision is supposed to produce. - Read the database's state first and stamp it last, so a half-applied revision cannot claim to be whole.
- Say what the revision destroys before running it.
0006deletes every row ofdiary_sessions; that is a sentence the owner needs before the transaction, not after.
The enum trap
SAEnum(SomeStrEnum) stores the member name. A server_default written as .value is
a string the ORM cannot read back — a LookupError in the bot's middleware, i.e. every
update at once. 0010 nearly shipped exactly that; tests/test_join_modes.py holds all
three sides of it.
SQLite
SQLite has no ALTER for constraints. 0011, 0012 and 0013 return early on
op.get_bind().dialect.name == "sqlite"; lifespan create_all covers local SQLite anyway.
Gates
tests/test_schema_version.py pins app/db.py:EXPECTED_REVISION to the real head — a new
revision that does not move it fails there. Then the usual three.
