Хочу рассказать про баг, который я искал полтора дня и который оказался опечаткой длиной в одну инструкцию. Если коротко: PostgreSQL (16.4 в моём случае, но воспроизводится начиная с 12-й) не разрешает UNION ALL сразу после ORDER BY ... LIMIT N без круглых скобок. И самое неприятное - ошибку об этом я узнал последним, потому что между моим SQL-запросом и логами стояло пять промежуточных слоёв, каждый из которых её по-своему проглотил.

Расскажу как нашёл, как фиксил, и что в итоге добавил в pre-deploy чек-лист.

Симптом: тихий пустой ответ от webhook

У меня есть n8n-воркфлоу, который раз в день проверяю - там GET /webhook/result отдаёт финальную карточку клиента в JSON: профиль, статус, прикреплённые объекты, лид-инфа. Простой read-only эндпоинт. Цепочка из шести нод: webhook trigger -> Postgres Load Session -> Postgres Load Lead -> Postgres Load Properties -> Code Build Card -> Respond to Webhook. Ничего хитрого.

В пятницу вечером фронтенд начал стабильно падать на странице результата с пустым ответом. Открыл DevTools - 200 OK, content-type application/json, body пустой. То есть никакой ошибки на сервере, просто 0 байт.

Самое раздражающее, что у меня в голове сразу выстроился неправильный причинно-следственный ряд: «фронт раньше работал, я последний раз правил рендер карточки, наверное где-то null-pointer». Полез смотреть фронт. Конечно ничего не нашёл (потому что нечего смотреть, body пустой). Откатил последний релиз - продолжает падать. Так что нет, дело не во фронте.

Перешёл смотреть n8n. Открываю executions - workflow выполняется, отдаёт ok: true, никаких error-выходов. То есть ноды формально пройдены. Но что-то по дороге становится пустым.

В понедельник утром, после кофе и с ясной головой, наконец сел открывать каждую ноду по очереди и смотреть Output руками. На второй ноде - Postgres: Load Lead - увидел что она отдаёт пустой массив []. Ну ок, лида для этой сессии может и не быть. Дальше код-нода Build Card ждёт минимум 1 строку (берёт [0]) и при [0] === undefined молча возвращает пустой объект. Который и улетает в Respond. Вот и весь пустой ответ.

Дальше начинается интересное.

Что было в SQL

Запрос в Load Lead был написан так (я писал его несколько недель назад, копируя свой же паттерн откуда-то с прошлого проекта):

SELECT summary, escalation_reason, created_at FROM leads
WHERE session_id = '...'
ORDER BY created_at DESC
LIMIT 1
UNION ALL
(SELECT NULL, NULL, NULL)
LIMIT 1

Идея логичная: «возьми последний лид для сессии, а если его нет - верни строку с NULL-полями, чтобы downstream-нода не споткнулась о пустой массив». Без UNION ALL n8n получал бы 0 items и обрывал цепочку.

Запрос вроде читается. Лид есть - вернёт его. Лида нет - первая часть пустая, UNION ALL приклеит NULL-fallback, итого LIMIT 1 ограничит до одной строки. Всё.

Берёшь этот SQL, скармливаешь psql:

psql ... -c "
SELECT 1 AS x WHERE 1=2 ORDER BY x DESC LIMIT 1 UNION ALL (SELECT NULL) LIMIT 1
"

И получаешь:

ERROR:  syntax error at or near "UNION"
LINE 2: SELECT 1 AS x WHERE 1=2 ORDER BY x DESC LIMIT 1 UNION ALL (S...

Сюрприз. PostgreSQL такое не понимает. Хотя по идее всё разумно.

Почему PostgreSQL так строг

Если копнуть в SQL:2011 спеку (или в документацию PostgreSQL про UNION) - грамматика <query expression> устроена так, что ORDER BY и LIMIT относятся к внешнему запросу, к результату всех UNION/INTERSECT/EXCEPT. То есть когда вы пишете:

SELECT a FROM t1 ORDER BY a LIMIT 1
UNION ALL
SELECT b FROM t2

Парсер не может решить, к чему относится ORDER BY a LIMIT 1 - к первому SELECT (что вы хотели) или вообще странная конструкция, потому что после LIMIT должен идти конец query, а не UNION.

Чтобы сказать парсеру «ORDER BY/LIMIT относится только к первому SELECT» - его надо обернуть в скобки. Тогда это становится <query primary>, который имеет право иметь свои собственные сортировки и лимиты.

Это не баг постгреса. Это так и задумано стандартом, чтобы не было неоднозначности. Но узнаёшь об этом только когда упрёшься.

Честно - я и до этого случая знал это правило теоретически, но никогда не задумывался об edge-кейсах с LIMIT ровно на той же строке что и UNION. В обычной практике ORDER BY всё-таки чаще ставят в самом конце, в <query expression>, и проблемы не возникает.

Три варианта фикса

После того как я понял в чём дело, у меня было три рабочих способа починить.

Вариант 1: круглые скобки

Самое прямолинейное решение - то самое, на которое прямо намекает спека:

(SELECT summary, escalation_reason, created_at FROM leads
 WHERE session_id = '...'
 ORDER BY created_at DESC LIMIT 1)
UNION ALL
SELECT NULL::text, NULL::text, NULL::timestamptz
LIMIT 1

Скобки превращают первую часть в <query primary> со своими ORDER BY/LIMIT. Парсер счастлив, запрос работает.

Подвох: если первая часть нашла запись, то после UNION ALL получится 2 строки (найденная + NULL-fallback), а внешний LIMIT 1 обрежет до первой. Но порядок строк в UNION ALL без явной сортировки не гарантирован. То есть теоретически вы можете получить NULL-строку даже когда лид существует. На практике PostgreSQL обычно отдаёт в порядке выполнения подзапросов и этого не происходит, но полагаться я бы не стал.

Чтобы гарантированно получать «реальную» строку первой - надо добавить ещё один уровень с ORDER BY по флагу:

SELECT summary, escalation_reason, created_at
FROM (
  (SELECT 0 AS prio, summary, escalation_reason, created_at FROM leads
   WHERE session_id = '...' ORDER BY created_at DESC LIMIT 1)
  UNION ALL
  SELECT 1 AS prio, NULL::text, NULL::text, NULL::timestamptz
) t
ORDER BY prio
LIMIT 1

Уже не так красиво. Поэтому я этот вариант отбросил.

Вариант 2: RIGHT JOIN с константной таблицей

Это то, что я в итоге выкатил. Идея простая: сделать join с гарантированно непустой константной строкой, чтобы downstream всегда получал ровно одну строку - либо с данными, либо с NULL-полями.

SELECT summary, escalation_reason, created_at FROM (
  SELECT summary, escalation_reason, created_at FROM leads
  WHERE session_id = '...'
  ORDER BY created_at DESC LIMIT 1
) t
RIGHT JOIN (SELECT 1 AS d) s ON true

Что тут происходит:

  • Внутренний SELECT ... LIMIT 1 живёт в скобках без проблем (это derived table)

  • (SELECT 1 AS d) - константа, гарантированно одна строка

  • RIGHT JOIN ... ON true означает: «возьми все строки правой части (всегда одна) и соедини с левой если есть пара». При ON true пара есть всегда. Если левая пустая - правая возвращает себя с NULL во всех полях левой

  • Внешний SELECT берёт только колонки из t (поле d из правой нам не нужно)

Итог: ВСЕГДА одна строка. Если в leads есть запись - получаем её значения. Если нет - все NULL.

Прелесть в том, что я не описываю NULL-fallback явно (нет SELECT NULL, NULL, NULL) - он автоматически возникает из левого join'а. Если завтра я добавлю в SELECT ещё одну колонку, мне не нужно править fallback в двух местах. Только в одном внутреннем запросе. А типы - PostgreSQL вычислит сам.

Главный минус решения - оно непривычное. Когда коллега видит такой запрос впервые, ему нужно секунд тридцать чтобы понять что происходит. Я добавил коммент над запросом «// Trick: RIGHT JOIN with constant guarantees exactly 1 row, NULL when empty» - этого хватает.

Performance? На таблице leads с 50k записей и индексом по session_id запрос отрабатывает за 1.2 ms. RIGHT JOIN с однострочной константой - бесплатный по сути. Ничего не оптимизируется хуже чем оригинал.

Вариант 3: scalar subqueries

Кратко - можно вообще обойтись без UNION и JOIN, если просто разнести каждую колонку в отдельный scalar subquery:

SELECT
  (SELECT summary           FROM leads WHERE session_id='...' ORDER BY created_at DESC LIMIT 1) AS summary,
  (SELECT escalation_reason FROM leads WHERE session_id='...' ORDER BY created_at DESC LIMIT 1) AS escalation_reason,
  (SELECT created_at        FROM leads WHERE session_id='...' ORDER BY created_at DESC LIMIT 1) AS created_at

Каждый subquery возвращает скаляр или NULL. Итого получаем строку из трёх колонок, каждая либо со значением, либо с NULL.

Минус очевидный - один и тот же WHERE/ORDER BY/LIMIT повторяется три раза. Не страшно для маленькой таблицы (планировщик может закешировать), но на большой может стать заметно медленнее. Плюс если завтра нужно добавить колонку - править в каждом subquery.

В моём случае таблица leads не размером с Кострому, но я всё равно выбрал RIGHT JOIN-вариант - он чище читается и компактнее.

Почему n8n проглотил ошибку

Самое обидное в этой истории не сам SQL-баг, а то, что я не увидел error-сообщения вовремя. Postgres-нода в n8n, когда executeQuery падает с syntax error, ведёт себя по-разному в зависимости от настроек:

  • On Error: Stop Workflow - workflow обрывается, error-сообщение видно в executions

  • On Error: Continue - workflow идёт дальше с пустым output, ошибки не видно

  • On Error: Continue (using error output) - workflow идёт дальше, но создаётся отдельный error-выход; ошибка доступна, если этот выход подключить к downstream-ноде

В моём workflow Load Lead был выставлен в Continue (using error output), но error-выход никуда не был подключен (просто потому что я не предусмотрел такой сценарий). Получилось: ошибка возникла, нода поставила 0 items на main-выход, error-выход остался обрывком. Workflow «успешно» завершился пустой картой.

Урок: если в Postgres-ноде SQL динамический (через expressions, через билдер, через template) - либо используйте Stop Workflow, чтобы любой syntax error падал громко, либо подключайте error-выход к Telegram-нотификации или к Sentry. Тихий error в продакшене - худшее что бывает.

Для моих воркфлоу я в итоге сделал гибрид: для read-only нод оставил Continue (потому что иногда легитимно ничего не находится), но добавил отдельную ноду-проверку перед Build Card: если предыдущий output пустой - сразу логирую event в Telegram. Это поймало бы мою же проблему ещё в пятницу.

Что добавил в pre-deploy чек-лист

Этот случай заставил меня формализовать одну вещь:

  • Любой SQL с UNION/INTERSECT/EXCEPT, у которого части содержат ORDER BY/LIMIT - обязательно прогоняется руками в psql ПЕРЕД сохранением в n8n. Не «потом проверим в браузере», не «всё равно же тестим воркфлоу через webhook» - именно через psql. Парсер n8n не равен парсеру psql, и иногда ошибка маскируется.

Заодно добавил себе универсальную проверку для любого нового SQL: запускаю с заведомо невыполнимым WHERE 1=2. Если получаю ожидаемый результат (пустая выдача либо корректный fallback) и структуру колонок такую же как при WHERE 1=1 - запрос можно деплоить. Это banalно но я как-то про это забывал, пока не получил полтора дня дебага.

Полезные ссылки

Если есть свой любимый паттерн «всегда вернуть 1 строку, даже когда таблица пустая» - был бы рад увидеть в комментариях. У меня их получилось три, но наверняка есть и четвёртый.

Комментарии (13)


  1. Akina
    07.05.2026 10:02

    Сюрприз. PostgreSQL такое не понимает. Хотя по идее всё разумно.

    Да нет тут ничего разумного. И вы дальше даже сами объясняете, по какой именно причине.

    При наличии ORDER BY / LIMIT в UNION правило архипростое. Есть скобки - применяется к подзапросу, нет скобок - ко всему запросу, если в тексте последнего подзапроса, иначе ошибка синтаксиса.

    Подвох: если первая часть нашла запись, то после UNION ALL получится 2 строки (найденная + NULL-fallback), а внешний LIMIT 1 обрежет до первой. Но порядок строк в UNION ALL без явной сортировки не гарантирован. То есть теоретически вы можете получить NULL-строку даже когда лид существует.

    Читаем правило выше и составляем правильный запрос:

    (
        SELECT summary, escalation_reason, created_at FROM leads
        WHERE session_id = '...'
        ORDER BY created_at DESC LIMIT 1
    )
    UNION ALL
    (
        SELECT NULL::text, NULL::text, NULL::timestamptz
    )
    ORDER BY created_at NULLS LAST -- вот он, фикс
    LIMIT 1

    Если первый подзапрос вернул запись, то ORDER BY, применяемый к объединённому набору записей, сначала вернёт запись с имеющимся значением поля, и только после неё запись со значением NULL. NULLS LAST в данном случае формально необязателен (но обязателен, если присутствует DESC - такова особенность сортировки у Postgres), но и беды от него никакой, а, поскольку сортируется не более 2 записей, то на производительность это не влияет.

    Обрамление ВСЕХ подзапросов скобками делает запрос ну совсем корректным. Хотя (опять же формально) обрамление скобками у последнего подзапроса можно и опустить. Но я советую делать наоборот, и каждый подзапрос обрамлять скобками, даже если у вас ни ORDER BY, ни LIMIT не присутствуют.


    1. viktdo Автор
      07.05.2026 10:02

      Поправка по делу, спасибо. Действительно, UNION ALL без внешнего ORDER BY порядок не гарантирует, и LIMIT 1 в моём варианте 1 теоретически может срезать найденную запись. Это баг, в продакшене стрельнул бы рано или поздно.

      Правильный вариант, как вы и привели: ORDER BY created_at NULLS LAST LIMIT 1 поверх объединённого набора. Тогда найденная запись всегда выигрывает у NULL-fallback'а.

      У меня в работающем флоу в итоге стоит RIGHT JOIN (вариант 2 в статье), там проблема снимается конструктивно: если внутренний SELECT пустой, RIGHT JOIN сам даёт NULL'ы, если вернул строку, она и есть результат. Но для «правильного UNION ALL» ваш вариант каноничный.

      Про обрамление скобками каждого подзапроса даже без ORDER BY/LIMIT согласен. Парсер прощает, но через полгода ты сам не помнишь, какое правило приоритета у UNION с LIMIT, и скобки сильно облегчают чтение. Особенно когда запрос дорастает до WITH или вложенных UNION.


      1. Akina
        07.05.2026 10:02

        Это баг

        Да не баг это, а совершенно нормальное, полностью законное и даже документированное поведение. Просто у вас оно выпало из памяти - не иначе, редко надобится. Или вы вообще с SQL "на вы", и не видите недетерминированности написанной вами конструкции. Я бы, наоборот, просто на автомате, даже не задумываясь, написал именно показанный мной запрос, для меня недетерминированность вашего запроса просто бросается в глаза.


        1. viktdo Автор
          07.05.2026 10:02

          Соглашусь, тут вы попали в точку. Я не профильный SQL-специалист, мой основной стек это n8n / интеграции / API-обвязка, а Postgres использую в роли хранилища данных для воркфлоу, иногда для обработки данных на стороне Posgres. То есть пишу SELECT’ы и JOIN’ы регулярно, а в углы спецификации UNION захожу раз в полгода. Эта недетерминированность для меня действительно не была очевидной из чтения запроса, я думал про неё на уровне «обычно работает» и не пошёл дальше.

          Ваш разбор как раз ценен тем, что показывает другой уровень владения инструментом: у человека с настоящим SQL-бэкграундом MAX-aggregate-обёртка уже в моторике, а не выводится из соображений. У меня так с n8n или JavaScript внутри Code-нод, где косяки чужих воркфлоу видны с первого взгляда. С Postgres мне до этого далеко, и комменты вроде ваших как раз тот ускоренный апдейт моторики, ради которого и пишутся такие статьи.


        1. viktdo Автор
          07.05.2026 10:02

          Могу использовать ваш комент, если соберусь написать статью по этой теме. Если будет применимо? С указанием на вас, конечно же


          1. Akina
            07.05.2026 10:02

            Ой, да ради бога.. можно даже ничего не указывать - секрета-то здесь никакого нет.


    1. ptr128
      07.05.2026 10:02

      Если уже заниматься перфекционизмом, то так. Внимание на последний запрос.

      Скрытый текст
      DROP TABLE IF EXISTS tmp_test;
      CREATE TABLE tmp_test (
        id serial PRIMARY KEY,
        session_id int,
        summary varchar,
        escalation_reason varchar,
        created_at timestamp
      );
      
      INSERT INTO tmp_test (session_id, summary,
        escalation_reason, created_at) 
      SELECT G.n/1000 AS session_id, G.n::text AS summary,
        'some reason' AS escalation_reason,
        transaction_timestamp()+'1 ms'::interval*G.n
      FROM generate_series(1,100000,1) G(n);
      
      CREATE INDEX tmp_test_session_id_created_at_idx
        ON tmp_test (session_id, created_at); 

      Сравниваем

      EXPLAIN ANALYZE
      SELECT summary, escalation_reason, created_at FROM (
        SELECT summary, escalation_reason, created_at
        FROM tmp_test
        WHERE session_id = 500
        ORDER BY created_at DESC LIMIT 1
      ) t
      RIGHT JOIN (SELECT 1 AS d) s ON true;
      
      Nested Loop Left Join  (cost=0.42..2.65 rows=1 width=25) (actual time=0.019..0.020 rows=1.00 loops=1)
        Buffers: shared hit=3
        ->  Result  (cost=0.00..0.01 rows=1 width=0) (actual time=0.000..0.001 rows=1.00 loops=1)
        ->  Limit  (cost=0.42..2.64 rows=1 width=25) (actual time=0.017..0.017 rows=0.00 loops=1)
              Buffers: shared hit=3
              ->  Index Scan Backward using tmp_test_session_id_created_at_idx on tmp_test  (cost=0.42..2.64 rows=1 width=25) (actual time=0.016..0.016 rows=0.00 loops=1)
                    Index Cond: (session_id = 500)
                    Index Searches: 1
                    Buffers: shared hit=3
      Planning Time: 0.121 ms
      Execution Time: 0.037 ms
      EXPLAIN ANALYZE
      (
        SELECT summary, escalation_reason, created_at
        FROM tmp_test
        WHERE session_id = 500
        ORDER BY created_at DESC LIMIT 1
      )
      UNION ALL
      (
        SELECT NULL::text, NULL::text, NULL::timestamptz
      )
      ORDER BY created_at NULLS LAST -- вот он, фикс
      LIMIT 1;
      
      Limit  (cost=2.69..2.69 rows=1 width=72) (actual time=0.023..0.024 rows=1.00 loops=1)
        Buffers: shared hit=3
        ->  Sort  (cost=2.69..2.69 rows=2 width=72) (actual time=0.023..0.023 rows=1.00 loops=1)
              Sort Key: (("*SELECT* 1".created_at)::timestamp with time zone)
              Sort Method: quicksort  Memory: 25kB
              Buffers: shared hit=3
              ->  Append  (cost=0.42..2.68 rows=2 width=72) (actual time=0.016..0.017 rows=1.00 loops=1)
                    Buffers: shared hit=3
                    ->  Subquery Scan on "*SELECT* 1"  (cost=0.42..2.65 rows=1 width=25) (actual time=0.015..0.015 rows=0.00 loops=1)
                          Buffers: shared hit=3
                          ->  Limit  (cost=0.42..2.64 rows=1 width=25) (actual time=0.014..0.015 rows=0.00 loops=1)
                                Buffers: shared hit=3
                                ->  Index Scan Backward using tmp_test_session_id_created_at_idx on tmp_test  (cost=0.42..2.64 rows=1 width=25) (actual time=0.014..0.014 rows=0.00 loops=1)
                                      Index Cond: (session_id = 500)
                                      Index Searches: 1
                                      Buffers: shared hit=3
                    ->  Subquery Scan on "*SELECT* 2"  (cost=0.00..0.02 rows=1 width=72) (actual time=0.001..0.001 rows=1.00 loops=1)
                          ->  Result  (cost=0.00..0.01 rows=1 width=72) (actual time=0.001..0.001 rows=1.00 loops=1)
      Planning Time: 0.109 ms
      Execution Time: 0.048 ms
      EXPLAIN ANALYZE
      SELECT MAX(T.summary) AS summary,
        MAX(T.escalation_reason) AS escalation_reason,
        MAX(T.created_at) AS created_at
      FROM (
        SELECT summary, escalation_reason, created_at
        FROM tmp_test
        WHERE session_id = 500
        ORDER BY created_at
        DESC LIMIT 1
      ) T;
      
      Aggregate  (cost=2.64..2.65 rows=1 width=72) (actual time=0.019..0.020 rows=1.00 loops=1)
        Buffers: shared hit=3
        ->  Limit  (cost=0.42..2.64 rows=1 width=25) (actual time=0.017..0.017 rows=0.00 loops=1)
              Buffers: shared hit=3
              ->  Index Scan Backward using tmp_test_session_id_created_at_idx on tmp_test  (cost=0.42..2.64 rows=1 width=25) (actual time=0.016..0.016 rows=0.00 loops=1)
                    Index Cond: (session_id = 500)
                    Index Searches: 1
                    Buffers: shared hit=3
      Planning Time: 0.103 ms
      Execution Time: 0.044 ms


      1. viktdo Автор
        07.05.2026 10:02

        Спасибо за бенчмарк, MAX-aggregate подход я не пробовал и он действительно элегантнее моего RIGHT JOIN-варианта. План у него получается короче на одну ноду (Aggregate + Limit + Index Scan против Nested Loop Left Join + Result + Limit + Index Scan), и это гарантированно одна строка по семантике агрегата, без танцев с константной правой таблицей.

        Один момент про safety этого паттерна: трюк работает только потому, что внутренний LIMIT 1 гарантирует не более одной строки на входе у MAX(). Если кто-то скопирует приём и уберёт LIMIT (или не заметит что подзапрос может вернуть >1 строки), то MAX(summary) и MAX(created_at) перестанут соответствовать друг другу. Это будут максимумы независимых колонок, и фронт получит «франкенштейн-строку» из несвязанных полей разных лидов. Я бы в продакшене вокруг такого запроса оставил коммент прямо в SQL: -- Aggregate trick: requires inner LIMIT 1, otherwise MAX() per-column desyncs rows.

        С этой оговоркой соглашусь, что вариант чище RIGHT JOIN'а. Обновлённая иерархия для случая «вернуть ровно одну строку или fallback с NULL»: Aggregate-обёртка > UNION ALL с NULLS LAST > RIGHT JOIN с константной таблицей. На практике разница в 0.01 ms незначима, но план короче, значит читать и поддерживать проще, и Postgres в более сложных запросах будет оптимизировать предсказуемее.


        1. ptr128
          07.05.2026 10:02

          трюк работает только потому, что внутренний LIMIT 1 гарантирует не более одной строки на входе у MAX()

          Ну так задача изначально звучала так. Была бы другая задача - был бы другой запрос.


      1. viktdo Автор
        07.05.2026 10:02

        Могу использовать ваш комент, если соберусь написать статью по этой теме. Если будет применимо? С указанием на вас, конечно же


    1. viktdo Автор
      07.05.2026 10:02

      Могу использовать ваш комент, если соберусь написать статью по этой теме. Если будет применимо? С указанием на вас, конечно же


  1. Zotann
    07.05.2026 10:02

    В чем посыл статьи? Так сразу понятно что последний лимит применяется неизвестно куда - то ли ко всему запросу, то ли ко второму (так же и с условиями и прочим). Поставить скобочки для ясности и все.


    1. viktdo Автор
      07.05.2026 10:02

      Если коротко: посыл не про сам SQL, а про то, сколько слоёв проглатывают syntax error прежде чем он доходит до разработчика. Сам фикс действительно тривиальный, в статье я и пишу, что баг искал не в нём, а в системе вокруг.

      Цепочка получилась такая: PostgreSQL-нода n8n с настройкой «Continue (using error output)» молча отдала пустой массив вместо исключения, downstream-нода с пустым массивом не упала а пробросила пустой объект, Respond to Webhook вернул HTTP 200 и пустое тело, фронт показал «нет данных», логи n8n executions показали статус success. Пять уровней, и ни на одном ошибка не материализовалась. Про SQL-pitfall я в итоге узнал последним, когда руками скопировал запрос в psql.

      Полезный для меня вывод оттуда не «не забывай про скобки», а «не используй Continue using error output без подключённого error-выхода». В проектах где много динамического SQL эта настройка превращает любую опечатку в тихий 200 OK.

      Кому скобки очевидны сразу, статья в первую очередь не для них. Для тех у кого в стеке n8n / Zapier / Make или любой low-code в продакшене, история про silent failures поверх SQL-ошибки полезнее самого SQL.