8d6a7c8308
JSONField serializes with JSONCodec, but columns declared as SQLAlchemy's own JSON type go through the engine's serializer instead, and no engine set one. That left Chat.chat - the largest blob the app stores - on stdlib json.dumps/loads no matter what ENABLE_ORJSON was set to, while the rest of the app used the codec. SQLAlchemy invokes it once per write and once per read, so every chat read and write paid a full stdlib pass over the whole conversation on top of whatever the caller did. Both engine constructors are now wrapped so the codec is wired in by default and cannot be missed by a call site that forgets it; an explicit json_serializer still wins. The 10 create_engine/create_async_engine calls in this module go through the wrappers. Vector-store engines (pgvector, mariadb, opengauss) are separate databases and are left alone. Serializing and deserializing chat-shaped blobs, median of 11 runs: | chat blob | write | read | | --- | --- | --- | | 600 msgs (2.8 MB) | 10.1 -> 1.7 ms | 8.4 -> 3.7 ms | | 3000 msgs (14.2 MB) | 51.9 -> 8.0 ms | 48.5 -> 27.8 ms | | 6000 msgs (28.5 MB) | 105.9 -> 29.5 ms | 112.7 -> 80.5 ms | With ENABLE_ORJSON off JSONCodec is stdlib json, so this is a no-op until the flag is set - the change cannot regress a default deployment. With it on, a round-trip probe through a native JSON column returns objects equal to the stdlib ones on all 12 shapes tried: ASCII, CJK, emoji, astral-plane, unicode keys, null bytes, lone surrogates, floats, ints above 2**63 and 2**64, line separators, empty and deeply nested. Stored text changes for non-ASCII, which is written as raw UTF-8 rather than backslash-uXXXX escapes and is correspondingly smaller. Nothing queries that text by escape except two Postgres safety filters in chats.py, and both still hold: a null byte is escaped identically by both codecs, and the title filter reads a text column rather than JSON. The ->> and json_extract searches decode the string before matching, so escaping cannot reach them. Two differences are inherent to JSONCodec and already apply to every JSONField column: ints beyond 2**64-1 come back as float, and NaN/Infinity serialize to null rather than the bare literals stdlib emits - the latter being invalid JSON that a Postgres json column rejects today. Neither shape occurs in chat blobs. Alembic builds its own engine and stays on stdlib, which is fine in both directions since each codec reads the other's output. Claude-Session: https://claude.ai/code/session_014BXoM6QiFJKisxcxKAXii8 Co-authored-by: Claude <noreply@anthropic.com>