note 26.400.0: updatedAt не отражает правки в редакторе, уведомления выключены по умолчанию
Ломающее обновление
Удалены или изменены публичные API — прикладной код может перестать работать.
В note 26.300.0 модуль «Базы знаний 2.0» получил события, историю версий и подписки. Версия 26.400.0 добавляет избранное, обратные ссылки между документами, отдельную материализацию markdown-проекции Yjs-документа, асинхронный пересчёт поиска и боковой чат BitrixGPT. Большую часть диффа снова занимает JS: editor.bundle.js, sidebar.bundle.js и новое расширение bitrix/js/note/ai-chat.
Для интеграций в нём ломаются пять вещей. updatedAt в REST v3 не отражает правки из редактора, уведомления по умолчанию не рассылаются, подписаться на документ без избранного нельзя, поиск догоняет правки агентом, а в конструкторах нескольких команд сдвинулись параметры. Пути ниже указаны от bitrix/modules/note/.
Что сломается
Синхронизируйте документы по contentUpdatedAt
В 26.300 компакция сохраняла документ через DocumentRepository::save(), тот выставлял UPDATED_AT, и отдельно вызывался setUpdatedBy(). Теперь проекция пишется через materializeProjection() и updatePartial(), а они UPDATED_AT не двигают (lib/Public/Command/CompactDocumentCommand.php). Поэтому updatedAt и updatedBy документа в REST v3 больше не отражают правки в редакторе (перезапись через REST note.document.update их по-прежнему двигает). Интеграция, которая синхронизирует документы по updatedAt, пропустит изменения из редактора.
Время правки текста отдаёт новое read-only поле contentUpdatedAt в DocumentItemDto для REST v3 и в Public\Provider\Dto\DocumentReadDto. У старых документов оно равно null, пока проекция не пересоберётся, так что берите более позднее из двух полей:
<?php declare(strict_types=1);
namespace Vendor\KbSync\Rest;
final class NoteDocumentStamp
{
/** Момент последнего изменения документа по ответу REST v3 note.document.* */
public static function changedAt(array $item): ?\DateTimeImmutable
{
$stamps = array_filter([$item['updatedAt'] ?? null, $item['contentUpdatedAt'] ?? null]);
if ($stamps === []) {
return null;
}
return max(array_map(static fn (string $s): \DateTimeImmutable => new \DateTimeImmutable($s), $stamps));
}
}
Включите доставку уведомлений
Configuration::isNotificationsUiEnabled() удалён, вместо него isNotificationsEnabled(). Опция осталась прежней, notifications_enabled со значением N по умолчанию. В 26.300 она прятала только колокольчик, а теперь выключает и доставку. NotificationDrainAgent::run() пропускает тик, пока опция не равна Y.
Портал, где опцию не трогали, после обновления перестанет рассылать уведомления по подпискам. Включённая опция возобновит доставку только с событий за последние сутки, накопившееся раньше до подписчиков не дойдёт.
<?php declare(strict_types=1);
use Bitrix\Main\Config\Option;
Option::set('note', 'notifications_enabled', 'Y');
Класс Internal\Configuration модуль к публичному API не относит. Если вы всё-таки проверяли флаг из своего кода, замените вызов на isNotificationsEnabled().
Добавьте документ в избранное до подписки
Для положительных режимов подписки SetSubscriptionCommand теперь требует запись в избранном. Без неё команда ничего не пишет и возвращает errorCode = FAVORITE_REQUIRED в данных Result, исключения нет. SubscriptionController::setAction в том же случае отдаёт ошибку FAVORITE_REQUIRED. Заглушить документ (mute) можно и без избранного. Добавлять в избранное нужно командой AddFavoriteCommand.
Отказ лежит в getData(), поэтому проверка isSuccess() его не заметит. А исключение из execute() базовый AbstractCommand::run() завернёт в CommandException. Обёртка, которая сводит оба случая к обычным ошибкам Result:
<?php declare(strict_types=1);
namespace Vendor\KbSync\Note;
use Bitrix\Main\Command\AbstractCommand;
use Bitrix\Main\Command\Exception\CommandException;
use Bitrix\Main\Error;
use Bitrix\Main\Result;
final class NoteCommandRunner
{
public function run(AbstractCommand $command): Result
{
try {
$result = $command->run();
} catch (CommandException $e) {
$cause = $e->getPrevious() ?? $e;
return (new Result())->addError(new Error($cause->getMessage(), $cause::class));
}
$code = $result->getData()['errorCode'] ?? null;
if (is_string($code)) {
return (new Result())->addError(new Error($code, $code));
}
return $result;
}
}
Не ищите документ сразу после правки в редакторе
Компакция больше не пересчитывает поисковый индекс после правок в редакторе. Она только ставит документу IS_DERIVED_STALE='Y', а поиск и индекс ссылок пересчитывает агент DerivedProjectionAgent раз в 300 с (lib/Infrastructure/Agent/Freshness/DerivedProjectionAgent.php). Выключателя у агента нет. Код, который ищет документ сразу после правки в редакторе, найдёт его по старому тексту. Запись через REST note.document.update индекс по-прежнему обновляет сама.
Сверьте позиционные вызовы конструкторов
В нескольких командах Public\Command и в одном провайдере параметры сдвинулись:
SaveYjsStateCommand: после$yjsStateвставленыbool $rebuiltFromMarkdown = falseи?string $markdownChecksum = null, в конец добавлен?DocumentUpdateRepository;OverwriteDocumentContentCommand:?string $operationIdстоит передDocumentRepository $documentRepository;RestoreDocumentVersionCommand:?string $operationIdстоит перед$versionRepository;CompactDocumentCommand: параметрSearchIndexService $searchIndexServiceудалён, в конец добавленEventRepository;DeleteCollectionCommand:?FavoriteRepositoryстоит перед$analyticsType;Public\Provider\SubscriptionProvider::__construct: второй параметрSubscriptionRepositoryзаменён наCoverageResolver.
Остальные изменённые команды расширили только в конце списка (чаще всего BacklinkLifecycleNotifier), их вызовы работают как раньше. Совместимо расширены и экшены синхронизации. CollaborationSyncController::saveYjsStateAction принимает $rebuiltFromMarkdown и $markdownChecksum, а savePatchAction добавляет в ответ поля patchId, prevPatchId, journalBaseId и compactSuggested. PushNotificationService::sendDocumentPatch(), sendDocumentContentOverwritten() и CollaborationProvider::buildCollaborationMeta() получили необязательные параметры в конце.
Новое
Работайте с избранным через команды
Избранным управляют экшены FavoriteController::addAction, removeAction, moveAction и listAction и команды AddFavoriteCommand, RemoveFavoriteCommand и MoveFavoriteCommand, а читать его можно через Public\Provider\FavoriteProvider::getPage() и getRow(). В выдаче CollectionController и Public\Provider\TreeProvider появился флаг isFavorite. Пару «избранное + подписка» сериализует FavoriteLockService, он берёт Connection::lock() на связку пользователя и объекта.
Включите обратные ссылки
Internal\Service\Link\DocumentLinkExtractor::extract() ищет в markdown ссылки на другие документы: токены @{document:<id>} и markdown-ссылки на /note/document/<id>/. Код и fenced-блоки он пропускает. Найденное DocumentLinkIndexService пишет в b_note_document_link. Если генерируете документы из своего кода и хотите, чтобы связи попали в индекс, ссылайтесь одним из этих двух способов.
Прочитать ссылки можно через Public\Provider\BacklinkProvider::getSources() и getCount() или экшенами DocumentController::getBacklinksAction и getBacklinksCountAction. Экшены работают только с включённым UI-флагом backlinks_enabled (по умолчанию N), иначе отвечают 403. Индекс для существующих документов строит разовый DocumentLinkBackfillAgent, а DocumentLinkRepairAgent запускается по требованию через DocumentLinkRepairScheduler.
Проекцию присылает клиент
CollaborationSyncController::materializeAction(int $documentId, string $markdown, int $uptoId) и команда Public\Command\MaterializeDocumentCommand принимают от клиента готовый markdown. Сервер записывает проекцию и курсор MATERIALIZED_UPTO_ID, только если присланный курсор опережает сохранённый. Журнал патчей, версии, события и UPDATED_AT при этом не меняются. Рядом работает Internal\Service\Collaboration\CollaborationEligibility. Документ, который перезапись перевела в обычный markdown, вернётся в совместный режим, только если клиент заявил rebuiltFromMarkdown и передал CRC-32 текста.
Проверьте условия бокового чата BitrixGPT
Конфигурацию виджета отдаёт AiChatController::getWidgetConfigAction(), action id note.infrastructure.AiChatController.getWidgetConfig. Доступность проверяет Internal\Integration\AiAssistant\AiChatAvailability, по порядку:
- флаг
ai_chat_enabled(по умолчаниюN); - модули aiassistant и im;
AiAssistantService::isBitrixGptV2Available('bitrixgpt_v2_available')и id бота;CopilotChat::isAvailable() && isActive().
WidgetApplicationDataResolver — обёртка над Bitrix\Im\V2\Application\Config. Сам диалог создаёт клиент через im.v2.Chat.add. Судя по комментариям, оба класса перенесли из biconnector и landing.
По мелочи
SubscriptionController::getStatesAction(int $collectionId, array $documentIds)отдаёт состояние подписок пачкой до 200 id, считает егоInternal\Service\Subscription\CoverageResolver.DocumentAccessService::buildListVisibilityFilter()строит предикат видимости какConditionTreeдля ORM-выборок, раньше фильтрация шла в PHP.CollectionAccessServiceкеширует коды доступа пользователя на время запроса, для тестов естьclearUserAccessCodesCache().DocumentController::restoreVersionAction(..., $operationId = null)принимает идентификатор операции из 32 hex-символов и возвращает его в push-уведомлении, так вкладка узнаёт свою операцию.- UI-флаги
backlinks_enabledиai_chat_enabledчитают новыеConfiguration::isBacklinksUiEnabled()иisAiChatEnabled(), оба флага по умолчанию выключены.
БД
Схема модуля описана в install/migrations/tables.php:
- в
b_note_documentдобавленыCONTENT_UPDATED_AT datetimeбез бэкфилла,MATERIALIZED_UPTO_ID bigintиIS_DERIVED_STALE char(1) default 'N'с индексомIX_NOTE_DOC_DERIVED_STALE (IS_DERIVED_STALE, ID); - в
b_note_eventновый индексIX_NOTE_EVENT_TYPE (SCOPE, ENTITY_ID, EVENT_TYPE, ID); - новая таблица
b_note_favoriteс полямиUSER_ID,ENTITY_TYPE,ENTITY_ID,POSITION,CREATED_AT, уникальным индексомUSER_ID+ENTITY_TYPE+ENTITY_IDи индексами для постраничной выборки и каскадной очистки; - новая таблица
b_note_document_linkс полямиSOURCE_IDиTARGET_ID, уникальной парой и покрывающим индексомIX_NOTE_DOCLINK_TARGET (TARGET_ID, SOURCE_ID).
В install/migrations/agents.php добавлены DerivedProjectionAgent::run раз в 300 с и DocumentLinkBackfillAgent::run раз в 300 с. Второй снимает себя сам после полного прохода, а на установленных порталах его, по комментарию, регистрирует апдейтер.
Мелочи и находки
ConditionTree::whereIn() с пустым массивом ничего не добавляет в фильтр (bitrix/modules/main/lib/ORM/Query/Filter/ConditionTree.php, строки 277–285). Докблок buildListVisibilityFilter() предупреждает, что без кодов доступа такой фильтр превратился бы в «всё, что кому-либо выдано». Поэтому модуль добавляет ветки только при непустом входе, а без веток возвращает заведомо ложное where($documentField, '<', 0).
Bitrix\Main\Command\AbstractCommand::run() заворачивает любое исключение из execute() в CommandException. Из-за этого catch (DocumentArchivedException) вокруг run() в CollaborationSyncController версии 26.300 не срабатывал ни разу, и клиент получал общую ошибку. Теперь addSyncError() достаёт причину через getPrevious().
На MySQL 5.6 сессия держит только одну именованную блокировку, и второй GET_LOCK молча отпускает первую. Connection::lock() в ядре устроен как раз через GET_LOCK (bitrix/modules/main/lib/db/mysqlcommonconnection.php:297). DerivedProjectionAgent это учитывает и берёт блокировку только на захват пачки. Очередью служит сам флаг IS_DERIVED_STALE, и агент сначала сбрасывает его, а потом переиндексирует документ.
Вложенный rollback в ядре откатывается к savepoint и бросает TransactionException('Nested rollbacks are unsupported.') (rollbackTransaction() в том же mysqlcommonconnection.php). Поэтому при отказе SetSubscriptionCommand коммитит пустую транзакцию вместо отката. В FavoriteLockService расписано, почему SELECT под REPEATABLE READ инвариант не защищает и нужна именованная блокировка.
CopilotChat::checkCopilotAvailability() модуль не вызывает намеренно. Через AIHelper этот метод регистрирует бота Copilot, если его нет, то есть пишет в БД прямо при рендере страницы (lib/Internal/Integration/AiAssistant/AiChatAvailability.php).
У параметров экшенов $rebuiltFromMarkdown, $markdownChecksum и $operationId тип не указан. Из urlencoded-запроса false приходит строкой "false", а operationId[]=x приходит массивом.
В выпуске есть заготовки без кода. Lang-файлы импорта из «Базы знаний 1.0» на Сайтах (lang/en/lib/Internal/Service/Import/Source/LandingSource.php, Transformer/Landing/HtmlToMarkdown.php, MenuConverter.php) и lang-файлы REST-исключений TreeTooLargeException и MoveAccessEscalationException уже лежат, самих классов в Infrastructure\Rest\V3\Exceptions нет (внутренний Internal\Exceptions\MoveAccessEscalationException есть с 26.300). CompactDocumentCommand по-прежнему называет RAG потребителем onDocumentContentSettled, но подписчика в дистрибутиве нет.
MATERIALIZED_UPTO_ID сразу сделали bigint. Курсор движется только вперёд, и усечённое значение навсегда заморозило бы MARKDOWN.