Просмотр исходного кода

fix(db): SQLite no longer reuses the ids of users, API keys, printers, archives and camera tokens

users, api_keys, printers, print_archives and long_lived_tokens are created
with AUTOINCREMENT; existing SQLite databases are rebuilt once at start. The
id counter starts above every id still referred to, by a foreign key or by
the columns that keep one without (print log, Notify tiles, tokens).
Restores keep this in both directions: a SQLite backup imported into
PostgreSQL starts each sequence above SQLite's counter and the ids still in
use, PostgreSQL installs imported earlier are raised at start, and a
PostgreSQL backup restored on SQLite has its counters raised. Counters are
never lowered.
maziggy 1 неделя назад
Родитель
Сommit
6b017efe1c

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **SQLite no longer gives a deleted user's, API key's, printer's or archive's number to a new one** — SQLite reuses the highest freed id, so whatever still referred to a deleted user, API key, printer, print archive or camera token could end up referring to the next one created. Earlier fixes cleaned up after each such case; these tables now never reuse an id at all. Existing SQLite databases are converted once at start (the rows, columns and indexes are kept), and ids still referred to (a deleted printer's print history, for example) are skipped. PostgreSQL never reused ids itself; a SQLite backup restored on PostgreSQL (including one restored before this release), or a PostgreSQL backup restored on SQLite, now keeps those ids unused too.
 - **Taking away live updates or camera access through a group now ends them** — Only creating a dashboard's live connection or a camera link checked the permission for it. When an admin removed that permission from a group, removed a user from the group that granted it, or turned off an API key's status access, a dashboard already open kept receiving live updates and could reconnect for up to an hour, and an open camera view kept running, its link opened new views and snapshots, and a Cam Wall or Home Assistant camera token kept working until it expired. Those connections and views are now closed and the links refused once the permission is gone; the dashboard falls back to refreshing on its own as it does for users without live updates.
 - **An open camera view stops once the viewer may no longer watch** — A live camera stream checked its link once, when it started, and then ran for as long as the tab stayed open. After a password change or reset, removing someone's two-factor authentication, deactivating a user, a backup restore, disabling or expiring an API key, revoking a Cam Wall or overlay token, a group change that takes the printer away, or turning authentication on, a view already open kept showing live video. Streams now check every 30 seconds that whoever opened them may still watch that printer, and end if not. A view that is still allowed keeps running, including past the hour its link is valid for.
 - **A tab whose live connection was closed by the server now recovers instead of freezing** — When the server closes the live-update connection because of sign-in (your session ended, authentication was just turned on, or a temporary check failed), the page used to stop updating and stay on screen until reloaded. It now re-checks your sign-in: a tab whose session ended goes to the login page, and one that is still signed in reconnects. While its connection keeps being refused or can't be made it waits longer between attempts, up to five minutes when the server closes an accepted connection again and up to 30 seconds otherwise, instead of retrying every three seconds.

+ 16 - 3
backend/app/api/routes/settings.py

@@ -1085,7 +1085,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path):
 
     from sqlalchemy import text
 
-    from backend.app.core.database import Base, _create_engine
+    from backend.app.core.database import AUTOINCREMENT_TABLES, Base, _create_engine, highest_referenced_id
 
     # Before anything is dropped. The route checks this too, earlier and with
     # the backup's version in the message; this call is what makes the guarantee
@@ -1106,6 +1106,11 @@ async def _import_sqlite_to_postgres(sqlite_path: Path):
             "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'archive_fts%'"
         )
         src_tables = {row["name"] for row in cursor.fetchall()}
+        # Where SQLite's own counters stand: ids freed there are not given out
+        # again on PostgreSQL either (a backup from before they were kept has none).
+        src_counters: dict[str, int] = {}
+        if src.execute("SELECT 1 FROM sqlite_master WHERE name = 'sqlite_sequence'").fetchone():
+            src_counters = {row["name"]: row["seq"] for row in src.execute("SELECT name, seq FROM sqlite_sequence")}
 
         # Get Postgres tables from our ORM models
         metadata = Base.metadata
@@ -1286,13 +1291,21 @@ async def _import_sqlite_to_postgres(sqlite_path: Path):
                 await conn.execute(insert_sql, batch)
                 logger.info("Imported %d rows into %s", len(batch), table_name)
 
-            # Reset sequences to max(id) + 1 for each table with an id column
+            # Reset sequences to max(id) + 1 for each table with an id column;
+            # for AUTOINCREMENT_TABLES past SQLite's counter and every id still
+            # referred to, so a deleted row's id is not given to a new one.
             for table_name in sorted_tables:
                 try:
                     async with conn.begin_nested():
                         result = await conn.execute(text(f"SELECT MAX(id) FROM {table_name}"))  # noqa: S608  # nosec B608
                         max_id = result.scalar()
-                        if max_id is not None:
+                        if table_name in AUTOINCREMENT_TABLES:
+                            max_id = max(
+                                max_id or 0,
+                                src_counters.get(table_name) or 0,
+                                await highest_referenced_id(conn, table_name),
+                            )
+                        if max_id:
                             seq_name = f"{table_name}_id_seq"
                             await conn.execute(text(f"SELECT setval('{seq_name}', {max_id})"))  # noqa: S608
                 except Exception:

+ 149 - 0
backend/app/core/database.py

@@ -332,6 +332,152 @@ def load_all_models():
     return Base.metadata
 
 
+# Tables SQLite must never give a freed id to a new row: whatever still points at
+# a deleted row (a token, a socket, in-memory state, files, a row inserted while
+# the delete committed) would point at the new one. PostgreSQL uses sequences.
+AUTOINCREMENT_TABLES = ("users", "api_keys", "printers", "print_archives", "long_lived_tokens")
+
+# Columns that keep one of their ids without a foreign key, so the seed below
+# cannot find them in the metadata: a deleted printer's print log is kept as
+# history, Notify tiles until the remote delete, ephemeral tokens until expiry.
+# (test_sqlite_autoincrement fails if a new such column is not listed.)
+NON_FK_ID_REFERENCES: dict[str, tuple[tuple[str, str], ...]] = {
+    "printers": (
+        ("print_log_entries", "printer_id"),
+        ("notification_live_activities", "printer_id"),
+        ("notification_lock_screen_widgets", "printer_id"),
+    ),
+    "api_keys": (("auth_ephemeral_tokens", "api_key_id"),),
+}
+
+
+async def highest_referenced_id(conn, table: str) -> int:
+    """The highest id of `table` in use: its own, or kept by a foreign key or
+    a column in NON_FK_ID_REFERENCES. A new row must get a higher one."""
+    from sqlalchemy import text
+
+    metadata = load_all_models()
+    referenced = (
+        [(table, "id")]
+        + [
+            (fk.parent.table.name, fk.parent.name)
+            for other in metadata.tables.values()
+            for fk in other.foreign_keys
+            if fk.column.table.name == table and fk.column.name == "id"
+        ]
+        + list(NON_FK_ID_REFERENCES.get(table, ()))
+    )
+    highest = 0
+    for ref_table, ref_column in referenced:
+        try:
+            async with conn.begin_nested():
+                value = (await conn.execute(text(f"SELECT max({ref_column}) FROM {ref_table}"))).scalar()  # noqa: S608  # nosec B608
+        except (OperationalError, ProgrammingError):
+            continue  # a table or column this database does not have yet
+        if isinstance(value, int) and value > highest:
+            highest = value
+    return highest
+
+
+async def _migrate_sqlite_autoincrement(conn) -> None:
+    """Rebuild AUTOINCREMENT_TABLES with AUTOINCREMENT on existing SQLite databases.
+
+    SQLite's own procedure: a copy made from the table's stored CREATE statement
+    (so every column an earlier migration added is kept) with the id made
+    ``INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT``, the rows copied and counted,
+    the old table dropped, the copy renamed, its indexes and triggers recreated.
+    Foreign keys are off on SQLite, so the tables pointing here keep their
+    references by name. The counter then starts above highest_referenced_id,
+    so an id freed earlier that something still points at is not given out
+    either. A table already rebuilt (or restored from a PostgreSQL backup,
+    whose counter stands at the highest id left) only has its counter raised.
+    """
+    from sqlalchemy import text
+
+    if not is_sqlite():
+        return
+    for table in AUTOINCREMENT_TABLES:
+        row = (
+            await conn.execute(text("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = :t"), {"t": table})
+        ).first()
+        if row is None:
+            continue
+        sql = row[0] or ""
+        if "AUTOINCREMENT" in sql.upper():
+            highest = await highest_referenced_id(conn, table)
+            current = (
+                await conn.execute(text("SELECT seq FROM sqlite_sequence WHERE name = :t"), {"t": table})
+            ).scalar()
+            if highest > (current or 0):
+                async with conn.begin_nested():
+                    await conn.execute(text("DELETE FROM sqlite_sequence WHERE name = :t"), {"t": table})
+                    await conn.execute(
+                        text("INSERT INTO sqlite_sequence (name, seq) VALUES (:t, :s)"), {"t": table, "s": highest}
+                    )
+                logger.info("SQLite: %s ids now continue after %s, still referred to", table, highest)
+            continue
+        column = re.search(r"\bid INTEGER NOT NULL\s*,", sql)
+        primary_key = re.search(r",\s*PRIMARY KEY \(id\)", sql)
+        if column is None or primary_key is None:
+            logger.warning("SQLite AUTOINCREMENT rebuild skipped for %s: unexpected table definition", table)
+            continue
+        copy = f"{table}__autoinc"
+        new_sql = sql[: primary_key.start()] + sql[primary_key.end() :]
+        new_sql = new_sql.replace(column.group(0), "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,", 1)
+        new_sql = re.sub(r"^CREATE TABLE\s+\"?" + re.escape(table) + r"\"?", f"CREATE TABLE {copy}", new_sql, count=1)
+        extras = [
+            r[0]
+            for r in await conn.execute(
+                text(
+                    "SELECT sql FROM sqlite_master WHERE type IN ('index', 'trigger') AND tbl_name = :t AND sql IS NOT NULL"
+                ),
+                {"t": table},
+            )
+        ]
+        async with conn.begin_nested():
+            await conn.execute(text(f"DROP TABLE IF EXISTS {copy}"))
+            await conn.execute(text(new_sql))
+            names = ", ".join(f'"{r[1]}"' for r in await conn.execute(text(f"PRAGMA table_info({table})")))
+            await conn.execute(text(f"INSERT INTO {copy} ({names}) SELECT {names} FROM {table}"))
+            original = (await conn.execute(text(f"SELECT count(*) FROM {table}"))).scalar_one()
+            copied = (await conn.execute(text(f"SELECT count(*) FROM {copy}"))).scalar_one()
+            if copied != original:
+                raise RuntimeError(f"{table} AUTOINCREMENT rebuild: {original} rows, {copied} copied")
+            await conn.execute(text(f"DROP TABLE {table}"))
+            await conn.execute(text(f"ALTER TABLE {copy} RENAME TO {table}"))
+            for statement in extras:
+                await conn.execute(text(statement))
+            highest = await highest_referenced_id(conn, table)
+            await conn.execute(text("DELETE FROM sqlite_sequence WHERE name = :t"), {"t": table})
+            await conn.execute(
+                text("INSERT INTO sqlite_sequence (name, seq) VALUES (:t, :s)"), {"t": table, "s": highest}
+            )
+        logger.info("SQLite: %s now never reuses an id (next id after %s)", table, highest)
+
+
+async def _raise_postgres_id_sequences(conn) -> None:
+    """Start AUTOINCREMENT_TABLES' sequences above every id still referred to (PostgreSQL).
+
+    PostgreSQL itself never reuses an id, but a SQLite backup imported before
+    the import did this set each sequence to the highest id left, below ids a
+    print log, a Notify tile or a token may still keep. Only ever raised.
+    """
+    from sqlalchemy import text
+
+    if is_sqlite():
+        return
+    for table in AUTOINCREMENT_TABLES:
+        sequence = (await conn.execute(text("SELECT pg_get_serial_sequence(:t, 'id')"), {"t": table})).scalar()
+        if not sequence:
+            continue
+        last_value, is_called = (await conn.execute(text(f"SELECT last_value, is_called FROM {sequence}"))).first()  # noqa: S608  # nosec B608
+        given_out = last_value if is_called else last_value - 1
+        highest = await highest_referenced_id(conn, table)
+        if highest > given_out:
+            await conn.execute(text("SELECT setval(:s, :v)"), {"s": sequence, "v": highest})
+            logger.info("PostgreSQL: %s ids now continue after %s, still referred to", table, highest)
+
+
 async def _drop_deleted_printers_from_limits(conn) -> None:
     """Take printers deleted before deletes did so out of API keys, Obico and virtual printers (both databases).
 
@@ -523,6 +669,9 @@ async def init_db():
 
         # Run migrations for new columns (SQLite doesn't auto-add columns)
         await run_migrations(conn)
+        # Before the orphan repair, so ids its rows still point at count.
+        await _migrate_sqlite_autoincrement(conn)
+        await _raise_postgres_id_sequences(conn)
 
         # SQLite applies no ON DELETE rules (foreign keys off): repair rows that
         # earlier deletes left pointing at nothing. Not a migration.

+ 2 - 0
backend/app/models/api_key.py

@@ -10,6 +10,8 @@ class APIKey(Base):
     """API key for external webhook access."""
 
     __tablename__ = "api_keys"
+    # SQLite would give a freed id to the next row; see core/database AUTOINCREMENT_TABLES
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100))  # User-friendly name

+ 2 - 0
backend/app/models/archive.py

@@ -8,6 +8,8 @@ from backend.app.core.database import Base
 
 class PrintArchive(Base):
     __tablename__ = "print_archives"
+    # SQLite would give a freed id to the next row; see core/database AUTOINCREMENT_TABLES
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)

+ 2 - 0
backend/app/models/long_lived_token.py

@@ -45,6 +45,8 @@ class LongLivedToken(Base):
     """Per-user, hashed-at-rest, revocable token for long-running camera viewers."""
 
     __tablename__ = "long_lived_tokens"
+    # SQLite would give a freed id to the next row; see core/database AUTOINCREMENT_TABLES
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
     user_id: Mapped[int] = mapped_column(

+ 2 - 0
backend/app/models/printer.py

@@ -8,6 +8,8 @@ from backend.app.core.database import Base
 
 class Printer(Base):
     __tablename__ = "printers"
+    # SQLite would give a freed id to the next row; see core/database AUTOINCREMENT_TABLES
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100))

+ 2 - 0
backend/app/models/user.py

@@ -23,6 +23,8 @@ class User(Base):
     """
 
     __tablename__ = "users"
+    # SQLite would give a freed id to the next row; see core/database AUTOINCREMENT_TABLES
+    __table_args__ = {"sqlite_autoincrement": True}
 
     id: Mapped[int] = mapped_column(primary_key=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)

+ 8 - 5
backend/tests/integration/test_user_delete_id_reuse.py

@@ -18,8 +18,8 @@ from backend.app.models.settings import Settings
 from backend.app.models.user import User
 
 
-async def _user(db, name: str) -> int:
-    user = User(username=name, password_hash="x", role="user")
+async def _user(db, name: str, user_id: int | None = None) -> int:
+    user = User(id=user_id, username=name, password_hash="x", role="user")
     db.add(user)
     await db.commit()
     await db.refresh(user)
@@ -67,9 +67,12 @@ async def test_a_user_with_the_deleted_users_id_inherits_nothing(async_client: A
 
     response = await async_client.delete(f"/api/v1/users/{alice}")
     assert response.status_code == 204, response.text
-    bob = await _user(db_session, "bob")
+    # AUTOINCREMENT (core/database) now stops SQLite reusing ids; the new user is
+    # put on the freed id by hand, as on databases from before, to keep the
+    # delete's own cleanup covered.
+    bob = await _user(db_session, "bob", alice)
 
-    assert bob == alice  # SQLite reuses the freed id; the point of this test
+    assert bob == alice
     assert await _inherited(db_session, bob) == NOTHING
     # What was alice's private cost centre and folder now has no owner, which
     # leaves them to admins (not to everyone).
@@ -157,7 +160,7 @@ async def test_the_next_user_with_the_id_is_not_asked_for_an_email_code(async_cl
 
     response = await async_client.delete(f"/api/v1/users/{alice}")
     assert response.status_code == 204, response.text
-    bob = await _user(db_session, "bob")
+    bob = await _user(db_session, "bob", alice)  # on the freed id, as before AUTOINCREMENT
 
     assert bob == alice
     assert await _get_email_2fa_enabled(db_session, bob) is False

+ 9 - 4
backend/tests/integration/test_user_delete_in_flight_work.py

@@ -37,18 +37,23 @@ from backend.app.services.slicer_api import SliceResult
 pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
 
 
-async def _user(db, name: str) -> int:
-    user = User(username=name, password_hash="x", role="user")
+async def _user(db, name: str, user_id: int | None = None) -> int:
+    user = User(id=user_id, username=name, password_hash="x", role="user")
     db.add(user)
     await db.commit()
     return user.id
 
 
 async def _replace_user(db, user_id: int) -> None:
-    """Delete the user and create the next one, which gets the same id on SQLite."""
+    """Delete the user and create the next one on the same id.
+
+    AUTOINCREMENT (core/database) now stops SQLite reusing ids; the id is given
+    by hand, as databases from before gave it, to keep the delete's own cleanup
+    covered.
+    """
     await db.execute(delete(User).where(User.id == user_id))
     await db.commit()
-    assert await _user(db, "bob") == user_id
+    assert await _user(db, "bob", user_id) == user_id
 
 
 def _slice_job(owner_id: int) -> SliceJob:

+ 6 - 4
backend/tests/integration/test_user_deleted_during_request.py

@@ -25,8 +25,8 @@ from backend.app.models.user import User
 pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
 
 
-async def _user(db, name: str) -> User:
-    user = User(username=name, password_hash="x", role="user")
+async def _user(db, name: str, user_id: int | None = None) -> User:
+    user = User(id=user_id, username=name, password_hash="x", role="user")
     db.add(user)
     await db.commit()
     return user
@@ -40,7 +40,9 @@ async def deleted_alice(db_session):
     held = User(id=alice.id, username="alice", password_hash="x", role="admin")
     await db_session.execute(delete(User).where(User.id == alice.id))
     await db_session.commit()
-    assert (await _user(db_session, "bob")).id == alice.id
+    # AUTOINCREMENT now stops SQLite reusing ids; bob is put on it by hand, as
+    # databases from before did, to keep the check covered.
+    assert (await _user(db_session, "bob", alice.id)).id == alice.id
     try:
         yield lambda: app.dependency_overrides.__setitem__(get_request_actor, lambda: held)
     finally:
@@ -65,7 +67,7 @@ async def test_the_check_knows_who_is_still_there(db_session):
     await db_session.execute(delete(User).where(User.id == alice.id))
     await db_session.commit()
     assert not await actor_still_exists(db_session, held)
-    await _user(db_session, "bob")  # given alice's id
+    await _user(db_session, "bob", held.id)  # on alice's id, as before AUTOINCREMENT
     assert not await actor_still_exists(db_session, held)
 
 

+ 2 - 2
backend/tests/unit/services/test_upload_failure_reason_2899.py

@@ -431,7 +431,7 @@ class TestWhatTheQueueEntrySays:
 class TestAnOwnerDeletedDuringTheUpload:
     async def test_the_failure_toast_goes_to_nobody(self, dispatch_case):
         """The dispatch read the owner before the upload. Deleted meanwhile, and
-        their id given to bob (SQLite reuses it), the toast must not go to bob."""
+        their id given to bob (as SQLite once did), the toast must not go to bob."""
         from sqlalchemy import delete
 
         import backend.app.services.print_scheduler as scheduler_module
@@ -452,7 +452,7 @@ class TestAnOwnerDeletedDuringTheUpload:
             async with dispatch_case.session_maker() as admin_db:
                 await admin_db.execute(delete(User).where(User.id == alice_id))
                 await admin_db.commit()
-                admin_db.add(User(username="bob", password_hash="x", role="user"))
+                admin_db.add(User(id=alice_id, username="bob", password_hash="x", role="user"))
                 await admin_db.commit()
             kwargs["failure"].failure = FtpFailure(FtpFailureKind.STORAGE, "553 Could not create file.", "553")
             return False

+ 223 - 0
backend/tests/unit/test_sqlite_autoincrement.py

@@ -0,0 +1,223 @@
+"""SQLite never hands a freed id of these tables to a new row.
+
+Without AUTOINCREMENT, SQLite gives the highest freed id to the next row
+created, and whatever still pointed at the deleted row (a token, a socket,
+in-memory state, a row inserted while the delete committed) then pointed at the
+new one. users, api_keys, printers, print_archives and long_lived_tokens are
+created with AUTOINCREMENT, and existing databases are rebuilt once.
+"""
+
+import re
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import AUTOINCREMENT_TABLES, Base, _migrate_sqlite_autoincrement, load_all_models
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+async def engine(tmp_path):
+    load_all_models()
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'db.sqlite'}")
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield engine
+    await engine.dispose()
+
+
+async def _sql(conn, name: str) -> str:
+    return (await conn.execute(text("SELECT sql FROM sqlite_master WHERE name = :n"), {"n": name})).scalar_one()
+
+
+async def _make_old_format(conn, table: str) -> None:
+    """Rebuild a table the way installs before this change have it."""
+    sql = await _sql(conn, table)
+    old = sql.replace("id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,", "id INTEGER NOT NULL,")
+    old = re.sub(r"\)\s*$", ", \n\tPRIMARY KEY (id)\n)", old)
+    old = old.replace(f"CREATE TABLE {table}", f"CREATE TABLE {table}__old", 1)
+    indexes = [
+        r[0]
+        for r in await conn.execute(
+            text("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = :t AND sql IS NOT NULL"),
+            {"t": table},
+        )
+    ]
+    await conn.execute(text(old))
+    await conn.execute(text(f"DROP TABLE {table}"))
+    await conn.execute(text(f"ALTER TABLE {table}__old RENAME TO {table}"))
+    for index in indexes:
+        await conn.execute(text(index))
+    assert "AUTOINCREMENT" not in await _sql(conn, table)
+
+
+async def test_new_installs_create_the_tables_with_autoincrement(engine):
+    async with engine.begin() as conn:
+        for table in AUTOINCREMENT_TABLES:
+            assert "AUTOINCREMENT" in await _sql(conn, table), table
+
+
+async def test_an_existing_table_is_rebuilt_keeping_its_rows_columns_and_indexes(engine):
+    async with engine.begin() as conn:
+        await _make_old_format(conn, "users")
+        for i in (1, 2, 3):
+            await conn.execute(
+                text(
+                    "INSERT INTO users (id, username, role, auth_source, is_active, created_at, updated_at) "
+                    "VALUES (:i, :n, 'user', 'local', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
+                ),
+                {"i": i, "n": f"u{i}"},
+            )
+        columns_before = [r[1] for r in await conn.execute(text("PRAGMA table_info(users)"))]
+
+        await _migrate_sqlite_autoincrement(conn)
+
+        assert "AUTOINCREMENT" in await _sql(conn, "users")
+        assert [r[1] for r in await conn.execute(text("PRAGMA table_info(users)"))] == columns_before
+        assert [r[0] for r in await conn.execute(text("SELECT username FROM users ORDER BY id"))] == ["u1", "u2", "u3"]
+        indexes = {
+            r[0]
+            for r in await conn.execute(
+                text("SELECT name FROM sqlite_master WHERE tbl_name = 'users' AND type = 'index'")
+            )
+        }
+        assert {"ix_users_username", "ix_users_email"} <= indexes
+
+
+async def test_a_deleted_highest_id_is_not_given_out_again(engine):
+    async def key(conn, name):
+        await conn.execute(
+            text(
+                "INSERT INTO api_keys (name, key_hash, key_prefix, can_queue, can_control_printer, "
+                "can_read_status, enabled, created_at) VALUES (:n, 'h', :n, 1, 0, 1, 1, CURRENT_TIMESTAMP)"
+            ),
+            {"n": name},
+        )
+
+    async with engine.begin() as conn:
+        await _make_old_format(conn, "api_keys")
+        await _migrate_sqlite_autoincrement(conn)
+        await key(conn, "k1")
+        await key(conn, "k2")
+        await conn.execute(text("DELETE FROM api_keys WHERE name = 'k2'"))
+        await key(conn, "k3")
+
+        assert (await conn.execute(text("SELECT id FROM api_keys WHERE name = 'k3'"))).scalar_one() == 3
+
+
+async def test_an_id_still_referenced_is_not_given_out(engine):
+    """Deleted before the rebuild, but a row still points at it (an API key
+    created while its user was deleted): the counter starts above it."""
+    async with engine.begin() as conn:
+        await _make_old_format(conn, "users")
+        await conn.execute(
+            text(
+                "INSERT INTO users (id, username, role, auth_source, is_active, created_at, updated_at) "
+                "VALUES (1, 'u1', 'user', 'local', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
+            )
+        )
+        await conn.execute(
+            text(
+                "INSERT INTO api_keys (name, key_hash, key_prefix, user_id, can_queue, can_control_printer, "
+                "can_read_status, enabled, created_at) VALUES ('k', 'h', 'bb_k', 7, 1, 0, 1, 1, CURRENT_TIMESTAMP)"
+            )
+        )
+
+        await _migrate_sqlite_autoincrement(conn)
+        await conn.execute(
+            text(
+                "INSERT INTO users (username, role, auth_source, is_active, created_at, updated_at) "
+                "VALUES ('u2', 'user', 'local', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
+            )
+        )
+
+        assert (await conn.execute(text("SELECT id FROM users WHERE username = 'u2'"))).scalar_one() == 8
+
+
+async def test_running_it_again_changes_nothing(engine):
+    async with engine.begin() as conn:
+        await _make_old_format(conn, "api_keys")
+        await _migrate_sqlite_autoincrement(conn)
+        before = {t: await _sql(conn, t) for t in AUTOINCREMENT_TABLES}
+
+        await _migrate_sqlite_autoincrement(conn)
+
+        assert {t: await _sql(conn, t) for t in AUTOINCREMENT_TABLES} == before
+
+
+async def test_a_printer_id_kept_by_the_print_log_is_not_given_out(engine):
+    """The log keeps a deleted printer's id on purpose, with no foreign key: the
+    next printer given it would show that history to whoever may see it."""
+    async with engine.begin() as conn:
+        await _make_old_format(conn, "printers")
+        await conn.execute(
+            text(
+                "INSERT INTO print_log_entries (printer_id, print_name, status, created_at) "
+                "VALUES (5, 'cube', 'completed', CURRENT_TIMESTAMP)"
+            )
+        )
+
+        await _migrate_sqlite_autoincrement(conn)
+
+        assert (await conn.execute(text("SELECT seq FROM sqlite_sequence WHERE name = 'printers'"))).scalar() == 5
+
+
+async def test_every_id_column_without_a_foreign_key_counts_for_the_seed():
+    """A new one must be listed, or ids it keeps could be given out at upgrade."""
+    from sqlalchemy import Integer
+
+    from backend.app.core.database import NON_FK_ID_REFERENCES
+
+    metadata = load_all_models()
+    listed = {pair for pairs in NON_FK_ID_REFERENCES.values() for pair in pairs}
+    found = {
+        (table.name, column.name)
+        for table in metadata.tables.values()
+        for column in table.columns
+        if re.search(r"(printer_id|user_id|api_key_id|archive_id|token_id|_by_id)$", column.name)
+        and isinstance(column.type, Integer)
+        and not column.foreign_keys
+        and not column.primary_key
+    }
+    assert found <= listed, found - listed
+
+
+async def _keys(conn, ids):
+    for i in ids:
+        await conn.execute(
+            text(
+                "INSERT INTO api_keys (id, name, key_hash, key_prefix, can_queue, can_control_printer, "
+                "can_read_status, enabled, created_at) VALUES (:i, :n, 'h', :n, 1, 0, 1, 1, CURRENT_TIMESTAMP)"
+            ),
+            {"i": i, "n": f"k{i}"},
+        )
+
+
+async def test_a_table_already_rebuilt_still_skips_ids_kept_elsewhere(engine):
+    """A PostgreSQL backup restored here has the tables with AUTOINCREMENT, its
+    counter at the highest id left: a token of a deleted key 7 is still there."""
+    async with engine.begin() as conn:
+        await _keys(conn, range(1, 7))
+        await conn.execute(
+            text(
+                "INSERT INTO auth_ephemeral_tokens (token, token_type, username, api_key_id, expires_at, created_at) "
+                "VALUES ('t', 'camera_stream', '', 7, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
+            )
+        )
+
+        await _migrate_sqlite_autoincrement(conn)
+
+        assert (await conn.execute(text("SELECT seq FROM sqlite_sequence WHERE name = 'api_keys'"))).scalar() == 7
+
+
+async def test_the_counter_is_never_lowered(engine):
+    async with engine.begin() as conn:
+        await _keys(conn, [10])
+        await conn.execute(text("DELETE FROM api_keys"))
+        await _keys(conn, [1])
+
+        await _migrate_sqlite_autoincrement(conn)
+
+        assert (await conn.execute(text("SELECT seq FROM sqlite_sequence WHERE name = 'api_keys'"))).scalar() == 10

+ 137 - 0
backend/tests/unit/test_sqlite_import_sequences.py

@@ -0,0 +1,137 @@
+"""A SQLite backup imported into PostgreSQL keeps ids freed on SQLite unused.
+
+The import set each sequence to the highest id left, so the next printer on
+PostgreSQL got the id of a deleted one, and with it whatever still pointed at
+that id (its print log history, a Notify tile, a token). The sequence now
+starts above SQLite's own counter and above the ids still referred to, and
+installs imported before this are repaired at start.
+"""
+
+import re
+import sqlite3
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+pytestmark = pytest.mark.asyncio
+
+
+def _source(path, *, deleted_highest: bool):
+    conn = sqlite3.connect(str(path))
+    conn.execute(
+        "CREATE TABLE printers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, serial_number TEXT, "
+        "ip_address TEXT, access_code TEXT)"
+    )
+    for i in range(1, 6):
+        conn.execute(
+            "INSERT INTO printers (id, name, serial_number, ip_address, access_code) VALUES (?, ?, ?, '192.0.2.1', 'x')",
+            (i, f"p{i}", f"S{i}"),
+        )
+    if deleted_highest:
+        conn.execute("DELETE FROM printers WHERE id = 5")
+    conn.commit()
+    conn.close()
+
+
+async def _import(path, pg_values: dict[str, int]) -> list[str]:
+    """Run the import on a fake PostgreSQL that answers max() queries from pg_values."""
+    from backend.app.api.routes import settings as settings_module
+
+    executed: list[str] = []
+
+    async def execute(stmt, *_a, **_k):
+        sql = getattr(stmt, "text", str(stmt))
+        executed.append(sql)
+        value = None
+        for pattern, v in pg_values.items():
+            if re.search(pattern, sql, re.IGNORECASE):
+                value = v
+        result = MagicMock()
+        result.scalar = MagicMock(return_value=value)
+        return result
+
+    conn = MagicMock()
+    conn.execute = AsyncMock(side_effect=execute)
+    conn.run_sync = AsyncMock()
+    begin = MagicMock()
+    begin.__aenter__ = AsyncMock(return_value=conn)
+    begin.__aexit__ = AsyncMock(return_value=False)
+    engine = MagicMock()
+    engine.begin = MagicMock(return_value=begin)
+    engine.dispose = AsyncMock()
+    with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
+        await settings_module._import_sqlite_to_postgres(path)
+    return executed
+
+
+def _setval(executed: list[str], table: str) -> int | None:
+    for sql in executed:
+        m = re.search(rf"setval\('{table}_id_seq', (\d+)\)", sql)
+        if m:
+            return int(m.group(1))
+    return None
+
+
+async def test_an_id_freed_on_sqlite_is_not_given_out_on_postgres(tmp_path):
+    path = tmp_path / "b.db"
+    _source(path, deleted_highest=True)
+
+    executed = await _import(path, {r"MAX\(id\) FROM printers": 4})
+
+    assert _setval(executed, "printers") == 5
+
+
+async def test_an_id_still_referred_to_is_not_given_out_on_postgres(tmp_path):
+    """A backup from before the counter was kept: the print log still says 7."""
+    path = tmp_path / "b.db"
+    _source(path, deleted_highest=False)
+
+    executed = await _import(path, {r"MAX\(id\) FROM printers": 5, r"max\(printer_id\) FROM print_log_entries": 7})
+
+    assert _setval(executed, "printers") == 7
+
+
+async def _start(pg: dict[str, tuple[int, bool]], referenced: dict[str, int]) -> list[tuple[str, int]]:
+    """Run the start-up step on a fake PostgreSQL: pg maps a table to its
+    sequence's (last_value, is_called), referenced to its highest id in use."""
+    from backend.app.core import database
+
+    raised: list[tuple[str, int]] = []
+
+    async def execute(stmt, params=None, *_a, **_k):
+        sql = getattr(stmt, "text", str(stmt))
+        result = MagicMock()
+        if "pg_get_serial_sequence" in sql:
+            result.scalar = MagicMock(return_value=f"public.{params['t']}_id_seq")
+        elif "last_value" in sql:
+            table = re.search(r"public\.(\w+)_id_seq", sql).group(1)
+            result.first = MagicMock(return_value=pg.get(table, (1, False)))
+        elif "setval" in sql:
+            raised.append((params["s"], params["v"]))
+        return result
+
+    conn = MagicMock()
+    conn.execute = AsyncMock(side_effect=execute)
+    with (
+        patch.object(database, "is_sqlite", return_value=False),
+        patch.object(database, "highest_referenced_id", AsyncMock(side_effect=lambda _c, t: referenced.get(t, 0))),
+    ):
+        await database._raise_postgres_id_sequences(conn)
+    return raised
+
+
+async def test_an_install_imported_earlier_is_repaired_at_start():
+    """Imported before the import kept them: the sequence stands at 4, the
+    print log still says 5."""
+    raised = await _start({"printers": (4, True)}, {"printers": 5})
+
+    assert raised == [("public.printers_id_seq", 5)]
+
+
+async def test_a_sequence_is_never_lowered():
+    assert await _start({"printers": (9, True), "users": (3, False)}, {"printers": 5, "users": 2}) == []
+
+
+async def test_a_sequence_not_yet_used_counts_from_its_start():
+    """last_value 1, never called: id 1 is still to come, so 1 in use must raise it."""
+    assert await _start({"users": (1, False)}, {"users": 1}) == [("public.users_id_seq", 1)]