diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index d977872c..21c710f4 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -3,6 +3,7 @@ import { BotIcon, CopyIcon, + EyeOffIcon, GlobeIcon, LockIcon, MessageSquareIcon, @@ -529,7 +530,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar 来自他人 )} - {isOwned && agent.published && !agent.is_private && ( + {(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && ( )} + {(isOwned || isAdmin) && !isBuiltin && !agent.published && !agent.is_private && ( + + + 未发布 + + )} {agent.is_private && ( { - const { list, total, isLoading, tab, emptyDescription, showNewButton } = opts; + const { list, total, isLoading, isFetching, tab, emptyDescription, showNewButton } = + opts; // keepPreviousData keeps the old page visible while a new page loads, so - // only show the spinner on the very first load (no rows yet). - if (isLoading && list.length === 0) { + // this full-height spinner only covers the very first load (no rows yet), + // or a filter change while the previous result was already empty (where + // isLoading stays false but there is nothing to keep on screen). + if ((isLoading || isFetching) && list.length === 0) { return ( -
+
+ {t.common.loading}
); @@ -208,8 +213,29 @@ export function AgentGallery() { return renderEmpty(emptyDescription, showNewButton); } return ( -
- {renderGrid(list)} +
+ {/* While a server-side search/tag/page query is in flight the stale + keepPreviousData rows stay mounted — dim them and float a spinner + on top so a slow backend query never looks like the filter was + ignored, and block clicks on results that are about to change. */} +
+ {renderGrid(list)} +
+ {isFetching && ( +
+
+ + 正在查询… +
+
+ )} )} - {isOwned && skill.published && ( + {(isOwned || isAdmin) && !isBuiltin && skill.published && ( )} + {(isOwned || isAdmin) && !isBuiltin && !skill.published && ( + + + {t.settings.skills.badgeUnpublished} + + )} {isCustom && !isBuiltin && skill.source === "agent" && ( {t.welcome.greeting} + {/* 用户排行榜入口:紧挨欢迎语的"CM助手",新标签打开免登录的公开排行榜页。 */} +
)}
diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index 943dbcd5..0724dbaa 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -482,6 +482,8 @@ export const enUS: Translations = { badgeFromOthersTitle: "Owned by user {user_id}", badgePublished: "Published", badgePublishedTitle: "Visible to all users", + badgeUnpublished: "Unpublished", + badgeUnpublishedTitle: "Not published yet — only visible to its owner", badgeStale: "Stale", badgeStaleTitle: "Idle for a long time — flagged by the curator", badgeArchived: "Archived", diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index 4e0e4b65..4c695f81 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -415,6 +415,8 @@ export interface Translations { badgeFromOthersTitle: string; badgePublished: string; badgePublishedTitle: string; + badgeUnpublished: string; + badgeUnpublishedTitle: string; badgeStale: string; badgeStaleTitle: string; badgeArchived: string; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 9c1215c1..64b22bca 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -462,6 +462,8 @@ export const zhCN: Translations = { badgeFromOthersTitle: "来自用户 {user_id}", badgePublished: "已发布", badgePublishedTitle: "该技能已对所有用户可见", + badgeUnpublished: "未发布", + badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见", badgeStale: "过时", badgeStaleTitle: "长期未使用,已被策展程序标记", badgeArchived: "归档", diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 79a1273b..36891e07 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -2,6 +2,7 @@ import type { AIMessage, Message } from "@langchain/langgraph-sdk"; import type { ThreadsClient } from "@langchain/langgraph-sdk/client"; import { useStream } from "@langchain/langgraph-sdk/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { QueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; @@ -298,25 +299,19 @@ export function useThreadStream({ ); for (const update of updates) { if (update && "title" in update && update.title) { - void queryClient.setQueriesData( - { - queryKey: ["threads", "search"], - exact: false, - }, - (oldData: Array | undefined) => { - return oldData?.map((t) => { - if (t.thread_id === threadIdRef.current) { - return { + const nextTitle = update.title; + updateCachedThreadLists(queryClient, (threads) => + threads.map((t) => + t.thread_id === threadIdRef.current + ? { ...t, values: { ...t.values, - title: update.title, + title: nextTitle, }, - }; - } - return t; - }); - }, + } + : t, + ), ); } } @@ -815,6 +810,31 @@ export interface ThreadsPageResult { hasMore: boolean; } +/** + * Apply ``transform`` to the thread array inside every cached + * ``["threads", "search"]`` entry. The sidebar queries cache a plain + * ``AgentThread[]`` while the paged chats list (`useThreadsPage`) caches a + * ``ThreadsPageResult`` object under the same key prefix, so fuzzy + * ``setQueriesData`` updaters must handle both shapes. + */ +function updateCachedThreadLists( + queryClient: QueryClient, + transform: (threads: AgentThread[]) => AgentThread[], +) { + queryClient.setQueriesData( + { queryKey: ["threads", "search"], exact: false }, + (oldData) => { + if (Array.isArray(oldData)) { + return transform(oldData); + } + if (oldData && Array.isArray(oldData.threads)) { + return { ...oldData, threads: transform(oldData.threads) }; + } + return oldData; + }, + ); +} + /** * Server-side paginated conversation list for the chats page. Unlike * `useThreads` (which loads a bounded recent window for the sidebar), this @@ -935,17 +955,8 @@ export function useDeleteThread() { } }, onSuccess(_, { threadId }) { - queryClient.setQueriesData( - { - queryKey: ["threads", "search"], - exact: false, - }, - (oldData: Array | undefined) => { - if (oldData == null) { - return oldData; - } - return oldData.filter((t) => t.thread_id !== threadId); - }, + updateCachedThreadLists(queryClient, (threads) => + threads.filter((t) => t.thread_id !== threadId), ); }, onSettled() { @@ -1031,27 +1042,18 @@ export function useUpdateThreadMetadata() { } }, onSuccess(_, { threadId, metadata }) { - queryClient.setQueriesData( - { - queryKey: ["threads", "search"], - exact: false, - }, - (oldData: Array | undefined) => { - if (oldData == null) { - return oldData; - } - return oldData.map((t) => - t.thread_id === threadId - ? { - ...t, - metadata: { - ...(t.metadata as Record | null), - ...metadata, - }, - } - : t, - ); - }, + updateCachedThreadLists(queryClient, (threads) => + threads.map((t) => + t.thread_id === threadId + ? { + ...t, + metadata: { + ...(t.metadata as Record | null), + ...metadata, + }, + } + : t, + ), ); }, }); @@ -1093,25 +1095,18 @@ export function useRenameThread() { }); }, onSuccess(_, { threadId, title }) { - queryClient.setQueriesData( - { - queryKey: ["threads", "search"], - exact: false, - }, - (oldData: Array) => { - return oldData.map((t) => { - if (t.thread_id === threadId) { - return { + updateCachedThreadLists(queryClient, (threads) => + threads.map((t) => + t.thread_id === threadId + ? { ...t, values: { ...t.values, title, }, - }; - } - return t; - }); - }, + } + : t, + ), ); }, }); diff --git a/frontend-web/src/strategy-components/Header.tsx b/frontend-web/src/strategy-components/Header.tsx index 8ff04b8d..e19189a2 100644 --- a/frontend-web/src/strategy-components/Header.tsx +++ b/frontend-web/src/strategy-components/Header.tsx @@ -1,4 +1,4 @@ -import { Bell, ChevronDown, FileText, Moon, Palette, Trophy, User } from "lucide-react"; +import { Bell, ChevronDown, FileText, Moon, Palette, User } from "lucide-react"; import { useState } from "react"; import { NotificationDialog } from "@/components/workspace/notification-dialog"; @@ -54,26 +54,6 @@ const Header = ({ collapsed }: HeaderProps) => { CM助手 )} - - {/* 用户排行榜入口:紧挨 CM助手,点击在新标签打开免登录的公开排行榜页。 */} -
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 4ee79f54..b2b5b9c0 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -330,7 +330,9 @@ Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / ### Schema Migrations / Manual Column-Add SQL (`scripts/sql/`) -Alembic auto-migration is **disabled** — `_run_alembic_upgrade(url)` is commented out in `persistence/engine.py`. On startup only `Base.metadata.create_all` runs, which creates **new tables** but never patches **existing** ones (no new columns, no new indexes). So a column added to an ORM model appears automatically on a brand-new deployment, but is **missing on any existing MySQL database** — and code reading/writing that column will error there until the column is added by hand. +Alembic auto-migration is **disabled** — `_run_alembic_upgrade(url)` is commented out in `persistence/engine.py`. On startup `Base.metadata.create_all` runs (creates **new tables** only), followed by **`_ensure_orm_columns_sync`** — a best-effort sweep that compares every existing table against the ORM metadata and `ALTER TABLE … ADD COLUMN`s any missing column that is nullable or carries a `server_default` (plus the indexes covering those new columns). It works on all backends (sqlite/mysql/postgres; column DDL compiled through the active dialect), so a simple add-column lands automatically on existing databases at the next restart. NOT NULL columns without a server_default, type changes, widenings, and constraint changes are **not** auto-patched — those still need the manual SQL below. Tests: `tests/test_ensure_orm_columns.py`. + +**MySQL index-key limit (required):** every index/unique key must fit MySQL's legacy 767-byte InnoDB prefix limit assuming utf8mb4 (4 bytes/char) — i.e. ≤191 chars total per key. Columns longer than that must use an explicit prefix index (`Index(..., mysql_length=191)` or `mysql_length={"col": N}` for composites) instead of `index=True`/`UniqueConstraint`, otherwise `create_all` fails with error 1071 on older MySQL servers. `scripts/audit_index_key_lengths.py` audits the whole metadata; run it after adding any indexed string column. **Convention (required):** whenever code adds a field to an existing table (a new `mapped_column` on an ORM model), you must, in the same change: 1. Keep the matching alembic version file under `persistence/migrations/versions/` (guarded with `_has_table`/`_has_column`), and diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/models.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/models.py index 5e5adc9b..bab17e68 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/models.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/models.py @@ -13,25 +13,35 @@ from __future__ import annotations from datetime import UTC, datetime -from sqlalchemy import Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from deerflow.persistence.base import Base from deerflow.persistence.types import BeijingDateTime, PortableJSON, PortableLongText +# MySQL InnoDB with the legacy 767-byte index-prefix limit (5.6, or 5.7 with +# COMPACT row format) allows at most 191 utf8mb4 chars per index key. Columns +# longer than that use an explicit prefix index (``mysql_length`` — ignored by +# SQLite/Postgres) instead of ``index=True``, otherwise ``create_all`` fails +# with error 1071 ("Specified key was too long"). + class KnowledgeNoteRow(Base): """Knowledge note main table (mirrors ``knowledge_notes``).""" __tablename__ = "knowledge_notes" + __table_args__ = ( + Index("ix_knowledge_notes_vault_path", "vault_path", mysql_length=191), + Index("ix_knowledge_notes_source_id", "source_id", mysql_length=191), + ) id: Mapped[str] = mapped_column(String(64), primary_key=True) title: Mapped[str] = mapped_column(String(512)) summary: Mapped[str | None] = mapped_column(Text) content_md: Mapped[str] = mapped_column(PortableLongText()) - vault_path: Mapped[str | None] = mapped_column(String(1024), index=True) + vault_path: Mapped[str | None] = mapped_column(String(1024)) source_type: Mapped[str] = mapped_column(String(64), index=True) - source_id: Mapped[str | None] = mapped_column(String(256), index=True) + source_id: Mapped[str | None] = mapped_column(String(256)) status: Mapped[str] = mapped_column(String(32), default="approved", index=True) confidence: Mapped[float] = mapped_column(Float, default=0.0) created_by: Mapped[str | None] = mapped_column(String(128), index=True) @@ -118,10 +128,15 @@ class KnowledgeEntityRow(Base): """Knowledge entity (phase 4, mirrors ``knowledge_entities``).""" __tablename__ = "knowledge_entities" - __table_args__ = (UniqueConstraint("name", "entity_type", name="uq_knowledge_entities_name_type"),) + __table_args__ = ( + Index("ix_knowledge_entities_name", "name", mysql_length=191), + # Prefix-unique: (120 + 64) * 4 = 736 bytes <= 767. Entity names are + # realistically far shorter than 120 chars, so the prefix is lossless. + Index("uq_knowledge_entities_name_type", "name", "entity_type", unique=True, mysql_length={"name": 120}), + ) id: Mapped[str] = mapped_column(String(64), primary_key=True) - name: Mapped[str] = mapped_column(String(256), index=True) + name: Mapped[str] = mapped_column(String(256)) entity_type: Mapped[str | None] = mapped_column(String(64)) created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC)) @@ -134,9 +149,10 @@ class KnowledgeGraphBlacklistRow(Base): """ __tablename__ = "knowledge_graph_blacklist" + __table_args__ = (Index("ix_knowledge_graph_blacklist_label", "label", unique=True, mysql_length=191),) id: Mapped[str] = mapped_column(String(64), primary_key=True) - label: Mapped[str] = mapped_column(String(512), unique=True, index=True) + label: Mapped[str] = mapped_column(String(512)) created_by: Mapped[str | None] = mapped_column(String(128)) created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC)) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py index 005a194f..5a71ce10 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import UTC, datetime -from sqlalchemy import BigInteger, Boolean, Integer, String, UniqueConstraint +from sqlalchemy import BigInteger, Boolean, Index, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from deerflow.persistence.base import Base @@ -86,7 +86,11 @@ class AgentSkillRow(Base): """ __tablename__ = "agent_skills" - __table_args__ = (UniqueConstraint("agent_id", "skill_name", name="uq_agent_skills_agent_skill"),) + # Prefix-unique instead of UniqueConstraint: (64 + 120) * 4 = 736 bytes + # fits MySQL's legacy 767-byte index limit (utf8mb4); a plain unique over + # agent_id + skill_name(191) would be 1020 bytes and fail create_all with + # error 1071 there. mysql_length is ignored on SQLite/Postgres. + __table_args__ = (Index("uq_agent_skills_agent_skill", "agent_id", "skill_name", unique=True, mysql_length={"skill_name": 120}),) id: Mapped[str] = mapped_column(String(64), primary_key=True) agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/engine.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/engine.py index a65f6136..69963f25 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/engine.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/engine.py @@ -194,6 +194,79 @@ async def _auto_create_mysql_db(url: str) -> None: await maint_engine.dispose() +def _ensure_orm_columns_sync(sync_conn) -> None: + """Add ORM-declared columns that are missing from already-existing tables. + + ``Base.metadata.create_all`` creates brand-new tables but never patches + existing ones, and Alembic auto-upgrade is disabled (see the commented-out + ``_run_alembic_upgrade`` call) — so on an existing database a new + ``mapped_column`` on an ORM model would otherwise error at first query + (e.g. ``no such column: roundtable_drafts.task_id`` on SQLite). + + This sweep compares each existing table against the ORM metadata and issues + ``ALTER TABLE … ADD COLUMN`` for every missing column that can be added + safely (nullable, or carrying a server_default). Indexes covering a newly + added column are created as well. Works on sqlite/mysql/postgres — the + column DDL is compiled through the active dialect, so portable types + (``PortableJSON``/``PortableLongText``/``BeijingDateTime``) resolve to the + right backend type. Everything is per-column best-effort: a failure is + logged and startup continues. + """ + from sqlalchemy import inspect as sa_inspect + from sqlalchemy.schema import CreateColumn + + from deerflow.persistence.base import Base + + try: + import deerflow.persistence.models # noqa: F401 — register all ORM models on Base.metadata + except ImportError: + pass + + inspector = sa_inspect(sync_conn) + existing_tables = set(inspector.get_table_names()) + preparer = sync_conn.dialect.identifier_preparer + + for table in Base.metadata.sorted_tables: + if table.name not in existing_tables: + continue + existing_cols = {c["name"] for c in inspector.get_columns(table.name)} + missing = [c for c in table.columns if c.name not in existing_cols] + if not missing: + continue + + added: set[str] = set() + for col in missing: + if not col.nullable and col.server_default is None: + logger.warning( + "Cannot auto-add NOT NULL column without server_default: %s.%s — add it manually", + table.name, + col.name, + ) + continue + try: + spec = CreateColumn(col).compile(dialect=sync_conn.dialect).string + sync_conn.exec_driver_sql(f"ALTER TABLE {preparer.quote(table.name)} ADD COLUMN {spec}") + added.add(col.name) + logger.info("Auto-added missing column %s.%s", table.name, col.name) + except Exception: + logger.exception("Failed to auto-add column %s.%s", table.name, col.name) + + if not added: + continue + try: + existing_idx = {ix["name"] for ix in inspector.get_indexes(table.name)} + except Exception: + existing_idx = set() + for idx in table.indexes: + if idx.name in existing_idx or not any(c.name in added for c in idx.columns): + continue + try: + idx.create(sync_conn) + logger.info("Auto-created index %s on %s", idx.name, table.name) + except Exception: + logger.exception("Failed to auto-create index %s on %s", idx.name, table.name) + + def _is_mysql_unknown_database_error(exc: Exception) -> bool: message = str(exc).lower() return "unknown database" in message or "1049" in message @@ -344,6 +417,15 @@ async def init_engine( else: raise + # Patch existing tables with ORM-declared columns that ``create_all`` + # cannot add (it only creates brand-new tables). Best-effort: a failure is + # logged and startup continues on the current schema. + try: + async with _engine.begin() as conn: + await conn.run_sync(_ensure_orm_columns_sync) + except Exception: + logger.exception("Auto-adding missing ORM columns failed; continuing with current schema") + # Apply in-place schema fixes (column widenings, etc.) that ``create_all`` # cannot do on its own. Failures don't abort startup — see the helper. # await _run_alembic_upgrade(url) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/feedback/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/feedback/model.py index bc8a2c48..7c7bab7d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/feedback/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/feedback/model.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import UTC, datetime -from sqlalchemy import String, Text, UniqueConstraint +from sqlalchemy import Index, String, Text from sqlalchemy.orm import Mapped, mapped_column from deerflow.persistence.base import Base @@ -14,7 +14,12 @@ from deerflow.persistence.types import BeijingDateTime class FeedbackRow(Base): __tablename__ = "feedback" - __table_args__ = (UniqueConstraint("thread_id", "run_id", "user_id", name="uq_feedback_thread_run_user"),) + # Prefix-unique instead of UniqueConstraint: a plain unique over three + # String(64) columns is (64+64+64)*4 = 768 bytes — one byte over MySQL's + # legacy 767-byte index limit (utf8mb4). Capping user_id's key part at 63 + # chars (ids are 36-char UUIDs in practice) brings it to 764 bytes. + # mysql_length is ignored on SQLite/Postgres. + __table_args__ = (Index("uq_feedback_thread_run_user", "thread_id", "run_id", "user_id", unique=True, mysql_length={"user_id": 63}),) feedback_id: Mapped[str] = mapped_column(String(64), primary_key=True) run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) diff --git a/offline-backend-20260512/backend/scripts/audit_index_key_lengths.py b/offline-backend-20260512/backend/scripts/audit_index_key_lengths.py new file mode 100644 index 00000000..93833d54 --- /dev/null +++ b/offline-backend-20260512/backend/scripts/audit_index_key_lengths.py @@ -0,0 +1,71 @@ +"""Audit ORM index/unique-key byte sizes against MySQL InnoDB prefix limits. + +MySQL with the legacy 767-byte InnoDB index-prefix limit (5.6, or 5.7 with +COMPACT row format) rejects any index whose key exceeds 767 bytes — and +utf8mb4 costs 4 bytes per char, so an indexed VARCHAR key may total at most +191 chars. Modern servers (DYNAMIC row format) allow 3072 bytes = 768 chars. + +Run: PYTHONPATH=. .venv/Scripts/python scripts/audit_index_key_lengths.py +""" + +from __future__ import annotations + +import sys + +from sqlalchemy import String, UniqueConstraint + +import deerflow.persistence.models # noqa: F401 (registers all ORM models) +from deerflow.persistence.base import Base + +LIMIT_OLD = 767 +LIMIT_NEW = 3072 +BYTES_PER_CHAR = 4 # utf8mb4 + + +def _col_key_bytes(col) -> int | None: + t = col.type + # unwrap TypeDecorator + impl = getattr(t, "impl", None) + if impl is not None: + t = impl + if isinstance(t, String): + return (t.length or 255) * BYTES_PER_CHAR + return 8 # ints/datetimes/floats — small fixed size, close enough + + +def _key_bytes(col, mysql_length) -> int: + full = _col_key_bytes(col) or 0 + if mysql_length is None: + return full + if isinstance(mysql_length, dict): + prefix = mysql_length.get(col.name) + else: + prefix = mysql_length + if prefix is None: + return full + return min(full, int(prefix) * BYTES_PER_CHAR) + + +def main() -> int: + bad = 0 + for table in Base.metadata.sorted_tables: + keys: list[tuple[str, str, list, object]] = [] + for idx in table.indexes: + keys.append(("INDEX", idx.name, list(idx.columns), idx.dialect_options["mysql"].get("length"))) + for cons in table.constraints: + if isinstance(cons, UniqueConstraint): + keys.append(("UNIQUE", cons.name or "?", list(cons.columns), None)) + for kind, name, cols, mysql_length in keys: + total = sum(_key_bytes(c, mysql_length) for c in cols) + if total > LIMIT_OLD: + bad += 1 + tag = "EXCEEDS 3072 (fails on ALL MySQL)" if total > LIMIT_NEW else "exceeds 767 (fails on legacy MySQL)" + detail = ", ".join(f"{c.name}({getattr(getattr(c.type, 'impl', c.type), 'length', '?')})" for c in cols) + print(f"{table.name}: {kind} {name} = {total} bytes [{detail}] -- {tag}") + if not bad: + print("OK: every index key fits within 767 bytes (utf8mb4 worst case)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())