Mongo-схема
Колекції products → tasks → creatives, дзеркало users із Clerk і версіонування документів.
Три пов’язані колекції: products → tasks → creatives. Каталог користувачів (users)
дзеркалить Clerk (синк через webhook). Enum-словники — джерело правди в kumquat_core.enums;
$jsonSchema в БД перевіряє лише структуру/типи, тож додати мову чи гео — це деплой, не міграція.
На кожному документі є schema_version для лінивих міграцій.
PRODUCTS — колекція products
Фіксований набір із 4 продуктів; _id — це код продукту (він же вживається в неймінгу креативів),
тож зовнішній ключ на задачах/креативах читабельний і не потребує денормалізованої копії.
_id(text) — код продукту, UNIQUE: PLT, CNI, RKI, SPDname(text) — повна назва: PlantIn, CoinIn, RockIn, SportDexschema_version,created_at,updated_at
TASK — колекція task__tasks
Одна задача — одна одиниця виробництва, що групує свої креативи.
━━━ ОСНОВНІ ━━━
name(text) — технічна назва карткиnumber(number) — авто-номер задачіgenerated_name(text) — обчислювана назва задачі
━━━ ТИП ЗАДАЧІ / ТАКСОНОМІЯ ━━━
product_id(→ products._id) — відношення до продуктуtask_type: NEW, VAR, LOC, WHT, NEWDES, VARDES, DES, CLN, VARAI, ADPT, RSZwork_stream: Performance, Product, Web, Brand, SMM, HRmedia_type: VID, STA, ANI, WEB, LND, ENDCexecution_complexity(number 1–5) — складність виконанняcreation_complexity(number 1–5) — складність креативного задумуpriority(number 1–3)
━━━ СТАТУСИ ━━━
status(TaskStatus): – To-do: backlog, ready_for_production – In progress: in_progress, tech_check, creative_check – Complete: done, archivedtest_status: not_tested, in_test, tested, paused
━━━ КРЕАТИВНІ ПАРАМЕТРИ ━━━
count(number) — к-сть Creative-картокresolution(без дефолту — задачу без нього прогон відхиляє, крім LOC/WHT/ADPT, які беруть розмір з оригіналу): 1080x1920, 1920x1080, 640x768, 800x800, 320x50, 1080x1350, 250x250, 300x250, 336x280, 200x200, 300x600, 1242x2688, 1080x1080, 600x800, 800x600source(дефолт META): META, TIK, APL, GGL, SNA, SKN, TAB, UNTgeo: US, FR, DE, CA, UA, ES, PT, BG, TW, KR, GR, CL, ZA, NZlanguage: EN, FR, DE, PT, ES, UA, CA-FR, IT, UK, TR, PL, JA, HE, MX, BR, DA, NO, CH, SV, NL, BE, IE, AT, CS, RO, HI, BG, ZH, KO, ELvaried_elements(multi): hook, cta, body_concept, background, hook_script, voice, music, personavar_description(text)
━━━ ВІДНОШЕННЯ ━━━
origin_creative_id(→ creatives._id) — вихідний креатив (для VAR/VARDES)source_creative_ids(→ creatives._id[]) — вихідні креативи (для LOC/WHT/ADPT/RSZ)
━━━ ROLLUP / FORMULA ━━━ (rollups, кеші, оновлює sync/rollup-джоб)
rollups.derived_task_count(count) — к-сть похідних креативівrollups.creation_points,rollups.execution_points(formula) — таблична залежність лише від власної складності (creation_complexity/execution_complexity): Level 1 → 0.5, Level 2 → 1.0, Level 3 → 2.0, Level 4 → 3.0, Level 5 → 5.0.null, поки відповідна складність не заповненаcomment_count(number, дефолт 0) — живі коментарі задачі; не вrollups, бо це єдине таке число, яке малює картка дошки, аTaskCardвесь блокrollupsвідкидає. Тримає його актуальним сам запис коментаря ($incразом із позначкою__last_comment_id), лагодитьkumquat-cli backfill-comment-counts— див. коментарі
━━━ ЛЮДИ ━━━ (→ users._id)
assignee_id— виконавецьtask_owner_id— постановник
━━━ ДАТИ ━━━ (усі, крім deadline_at, — тільки воркфлоу, по HTTP не виставити)
deadline_at— бажана дата здачі; час — опційний, і00:00UTC означає «час не задано» (див.kumquat_core.optional_time)started_at— вхід уin_progresssubmitted_at— віддано на Tech/Creative Checkcompleted_at— прийнято наdone
━━━ ІНШЕ ━━━
is_legacy(bool) — старий формат назвиmanual_launch(bool)legacy_remap_list(text)drive_url(url) — папка задачі
━━━ PRODUCT STACK / MARKETING FUNNEL ━━━ (заповнюються вручну при створенні брифу; free-text
поля — заглушки до появи власних колекцій; subject/topic/audience беруться зі словників
продукту, Product.dictionaries)
funnel(text),hypothesis(text)subject(text),topic(text),subtopics(text[])traffic_flow: W2A, WEBlanding(text — пізніше Relation → Landing DB)
━━━ VARIANTS (variants, list of TaskVariant) ━━━ — по одному запису на майбутній креатив,
для NEW/NEWDES/VAR/VARDES/VARAI. Це те, що раніше називалося Creative Stack і
заповнювалося один раз на всю задачу — тепер кожна майбутня варіація отримує власний бриф, і
creative_factory._scratch_card бере ці поля з variants[n - 1], а не з полів задачі. LOC/
WHT/ADPT/RSZ не мають variants взагалі — їхня картка будується з оригінального креативу.
key(text) — клієнтський ідентифікатор блоку, не показується в назві й ні на що не впливає на бекендіvariation_num(int, 1..count) — яку картку заповнює цей блокprimary_anchor: pain_led, desire_ledaudience(text — зі словника продукту)awareness_stage: unaware, problem_aware, solution_aware, product_aware, most_awaremessaging_angle: pattern_interrupt, proof_driven, trust_drivenhook_trigger: pattern_interrupt, identity_call_out, pain_agitation, curiosity_gap, social_proof_credibility, contrarian_myth_busting, aspiration_desire, urgency_stakeshook_type: 24 значення (бриф-девайс хука; не плутати з VLMhook.format/hook.script)ad_format: ~86 значень (формат подачі креативу)notes(text) — коментар креативного маркетологаtag_slugs(text[]) — довільні теги цієї варіації, зі спільного словникаcreatives__tags(див. нижче)description(text, BlockNote JSON) — ТЗ саме до цієї варіації, окремо від загального ТЗ задачі (var_description)
Задача, створена до появи variants (або будь-яка задача, для якої це поле лишили порожнім),
досі має старі скалярні поля primary_anchor .. notes прямо на задачі — вони не видалені,
_scratch_card читає їх як фолбек, коли variants порожній. Нове створення завжди пише в
variants; ці скалярні поля існують лише для сумісності зі старими документами.
━━━ CREATIVE TAGS — колекція creatives__tags ━━━ — довідник тегів, на які посилається
variants[].tag_slugs/Creative.tag_slugs. Будь-хто може завести новий тег прямо з поля Tags
(потрапляє як proposed); адмін підтверджує його (approved) або відхиляє (archived) зі
сторінки /creative-tags.
slug(text, UNIQUE) — похідний відlabel, те, що зберігається вtag_slugs. Правило одне (kumquat_creatives.tag_slug.normalize_tag_slug, дзеркало —frontend/src/modules/creative-tags/model/slug.ts): lowercase, і кожна послідовність усього, що не літера й не цифра (пробіли, пунктуація, дефіси, підкреслення), стає одним_; кінці обрізаються; максимум 32 символи, з обрізанням по межі слова. Літери будь-якої мови лишаються, тож«Зимова акція»→зимова_акція. Томуugc-hook,UGC hookіugc_hook— один тег, а не три. Нормалізує сервер (і в тілі, і в шляху), не клієнтlabel(text, UNIQUE без урахування регістру) — назва для показу. Унікальний, бо поле Tags пропонує саме лейбли й мапить обраний назад у slug: два однакових лейбли зробили б один із тегів невибираним. Лейбл мусить містити хоч одну літеру чи цифру, інакше 422 — з нього нічого було б вивести- Перейменування рухає
slugразом із лейблом (TagsService.set_label), бо slug — похідний, і розійтися вони не можуть. Тому це міні-міграція: переписуютьсяcreatives.tag_slugsіtask__tasks.variants[].tag_slugsусіх носіїв. 409, якщо новий лейбл або похідний від нього slug уже чужі (два різних лейбли можуть давати один slug). Зміна лише регістру slug не рухає, тож нічого й не переписує. Атомарності немає (ні транзакцій, ніarrayFiltersу тестах): посилання їдуть першими, тож збій на півдорозі лікується повторним тим самим запитом status: proposed, approved, archivedcreated_by(→ users._id)
Підтвердження тега (зміна status) — це запис лише в creatives__tags: жоден креатив чи
варіація, що вже посилається на цей slug, не переписується. Це стосується саме статусу —
перейменування, навпаки, переписує (див. вище).
Адмін також може завести тег прямо зі сторінки /creative-tags — такий одразу approved, бо саме
додавання і є тим рішенням, на яке чекає proposed — і видалити тег назовсім. Видалення
дозволене лише поки жоден креатив не несе цей slug (інакше 409): схема фільтрів креативів
пропонує як tag_slugs саме цю колекцію, тож видалений тег зробив би кожен креатив із ним
незнаходжуваним. Тег, який уже використовували, виводять з обігу через archived.
CREATIVE — колекція creatives
Один документ на один креатив-варіант.
━━━ ОСНОВНІ ━━━
name(text, UNIQUE) — канонічна назва (джерело правди, будується скриптом)product_id(→ products._id) — rollup продукту з задачіtask_type: NEW, VAR, LOC, WHT, NEWDES, VARDES, DES, CLN, VARAI, ADPT, RSZwork_stream: Performance, Product, Web, Brand, SMM, HRmedia_type: VID, STA, ANI, WEB, LND, ENDCexecution_complexity(number 1–5) — складність виконанняcreation_complexity(number 1–5) — складність креативного задумуvariation_num(number) — номер варіації в задачіmodifier: HK1, HK2, HK3, UC, AI, RSIZ, CTA1, CTA2, PXresolution: 1080x1920, 1920x1080, 640x768, 800x800, 320x50, 1080x1350, 250x250, 300x250, 336x280, 200x200, 300x600, 1242x2688, 1080x1080, 600x800, 800x600source: META, TIK, APL, GGL, SNA, SKN, TAB, UNTgeo: US, FR, DE, CA, UA, ES, BG, TW, KR, GR, CL, ZA, NZlanguage: EN, FR, DE, PT, ES, UA, CA-FR, IT, UK, TR, PL, JA, HE, MX, BR, DA, NO, CH, SV, NL, BE, IE, AT, CS, RO, HI, BG, ZH, KO, EL
━━━ СТАТУСИ ━━━
test_status: not_tested, in_test, tested, pausedperformance_tier: promising, potential, pre_scale, scale, top_performerflagged_for_review(bool)is_whitened(bool) — потребує вайтлістінгу- (робочий статус — rollup зі статусу задачі, резолвиться через
task_id;is_legacyживе на задачі)
━━━ МЕТРИКИ ━━━ (metrics — останній снапшот; історії поки немає, див. creative_metrics_ts нижче)
Блок пише цілком тільки батч kumquat-creative-metrics (ClickHouse → Mongo),
атомарним $set на весь піддокумент. Усі поля nullable: документи, старші за
скоркард, мусять валідуватися, бо їх читає kumquat-ch-sync. Інваріанти, заміряне
покриття джерела й семантика для людей — README пакета kumquat-creative-metrics.
Легасі-скаляри (чинний контракт UI-карток, фільтрів і сортів) — кожен є проєкцією вікна нижче, ставки завжди частки 0–1, не відсотки:
metrics.ctr=w.d7.ctr.v,metrics.hook_rate=w.d7.hook.v,metrics.hold_rate=w.d7.hold.v—vv15під ними означає 15-секундний перегляд у картки з атрибуційного фіда і 6-секундний у картки зsource = TIK(TikTok 15-секундних не публікує), тож одне поле несе два трохи різних питанняmetrics.spend=w.life.spend,metrics.purchases=w.life.purchasesmetrics.cpi,metrics.ipm— від lifemetrics.updated_at=computed_at
Три вікна metrics.w.{d7,t7,life}, кожне однакової форми:
- лічильники:
imp,vv,vv15,clicks,spend,purchases,installs - ставки з Wilson-інтервалом:
hook,hold,ctr— кожна{v, lo, hi, hw, n},vможе бути null cpm,cpa(cpa— відpurchases, тієї самої колонки, що й сторінка аналітики)- лише t7:
hook_delta,hold_delta(проти попередніх 7 днів) - лише life:
ltv,roas(модельний прогноз, не факт),trials - лише life і лише TikTok:
likes,comments,shares,follows,profile_visitsта власна атрибуція TikToktt_app_install,tt_registration,tt_purchase,tt_conversion,tt_skan_conversion. У картки, якої звіт TikTok не бачив, усі вониnull, а не0: нуль читався б як «поміряли й вийшло нічого». Префіксtt_навмисний —tt_purchaseіpurchasesрахують ту саму подію різними правилами й не складаються
d7 прибите до старту креатива (порівнюване між креативами), t7 — до watermark (для kill/scale), life — облік, не сигнал якості.
Оцінки й контекст:
metrics.status: learning, mature, stalemetrics.confidence.{hook,hold,ctr}: ok, thin, nonemetrics.bench:{hook_med, hold_med, cohort_n, hook_index, hold_index}— d7-ставки проти медіани 90-денної когорти свого продукту, 1.0 = медіана. Усе null, якщоd7_truncatedабо когорти немаmetrics.d7_truncated(bool) — креатив уже крутився до початку імпортованої історії, тож його «перші 7 днів» не перші; з бенчмарку виключений. У TikTok-картки історій дві (фід і звіт TikTok) і прапорець ставиться за пізнішою з нихmetrics.product— продукт із найбільшими показами в CH; може розходитись ізproduct_id, і саме за ним береться когортаmetrics.first_seen,last_seen,days_live,active_days
Походження й свіжість:
metrics.defn_version— версія контракту документа,metrics.run_idmetrics.computed_at(коли крутився DAG) іmetrics.data_through(останній день даних) — це різні речі; злиття їх ховає застряглий імпорт за свіжим таймстемпомmetrics.conv_provisional_from— конверсії й ltv після цієї дати ще попливутьmetrics.media_sources— скоуп run-у ([]= всі джерела)
━━━ VLM (vlm, nullable до аналізу) ━━━
vlm.analyzer_version,vlm.model,vlm.analyzed_at,vlm.notesvlm.rev— фінгерпринт дайджеста кроку аналізу (якmedia.rev): охоронець проєкції від повторного запису, не аналітичний вміст
Майже кожен закритий словник має other_unrecognized: JSON-схема Gemini не
дозволяє значення поза списком, тож без цього виходу модель була б змушена
вгадувати найближче — і підрахунки «який елемент спрацював» тихо збирали б у
найпопулярніше значення все, що насправді туди не належить. Виняток —
cast (no_person) і visual_style (mixed), де прогалину вже покрито.
null означає інше: ознаки немає в цьому сегменті взагалі.
HOOK (vlm.hook):
format: pov_selfie_talking, ugc_testimonial, person_reaction, green_screen_reaction, object_close_up, screen_record_demo, social_screen, text_animated, text_on_screen_static, before_after_reveal, unboxing_reveal, split_screen, vo_visuals, other_unrecognizedscript: question, fact, problem, claim, challenge, warning, teaser, instruction, fomo, story_anecdote, other_unrecognizedmarketing_angles(multi): pain_callout, curiosity_gap, bold_claim_stat, question_hook, social_proof_open, direct_address, other_unrecognizedvisual_triggers(multi): surprising_object_reveal, shock_claim_text, before_after_contrast, pattern_interrupt_motion, question_overlay, relatable_scenario, other_unrecognizedcast: woman, man, couple, kid_family, no_person, multiple_peoplecamera_angle: selfie_handheld_pov, static_tripod, macro_close_up, screen_recording, other_unrecognizedsetting: home_interior, outdoor_nature, store_shop, car, studio_plain_bg, screen_only, other_unrecognizedsubject(text — відкритий словник),face_visible(bool),duration_sec(number)description,on_screen_text,pain_point,vo(text)
BODY (vlm.body) — усі поля null для відео без окремого body-сегмента:
format: demo_walkthrough, feature_carousel, testimonial_montage, before_after_result, educational_explainer, reviews_social_proof_screen, other_unrecognizedscript: problem_solution, how_it_works, facts_tips, benefits_list, before_after, tutorial_steps, social_proof, statistics, urgency, other_unrecognizedconcept(text — складена таксономія Scan:ID тощо)marketing_beats(multi): pain_problem, agitation, solution_intro, feature_benefit, rationale_credibility, social_proof, urgency_scarcity, cta, other_unrecognizedcast,camera_angle,setting(ті самі словники, що й у hook),face_visible(bool)description,vo(text)cta_type: download_free, free_trial, limited_time_offer, direct_benefit_cta, other_unrecognized
CONTENT (vlm.content):
app_features_shown(text[]),proof_types(text[])solution_framing(text),primary_pain_point(text)emotional_driver: curiosity, fear_of_missed_value, relief, pride_collection_completion, urgency, nostalgia, surprise_delight, other_unrecognizedvisual_realism: photorealistic_live_action, realistic_ai_generated, cartoon_2d, cgi_3d, stylized_flat_graphic, mixed_hybrid, other_unrecognizedvisual_style: ugc, screen_demo, motion_graphics, ai_video, text_based, mixedpacing: fast_cut, medium, slow_cinematic, other_unrecognized
━━━ ВІДНОШЕННЯ ━━━
task_id(→ tasks._id) — обов’язково, 1 задача на креативorigin_ref({_id→ creatives._id,name}) — розширене посилання на оригінал (LOC/WHT/ADPT/RSZ)
━━━ PRODUCT STACK / MARKETING FUNNEL ━━━ (rollup з task; той самий набір полів і той самий словник значень, що й у TASK вище)
funnel,hypothesis,subject,topic,subtopics,traffic_flow,landing
━━━ CREATIVE STACK / TAGS / BRIEF ━━━ — rollup з відповідного TaskVariant (task.variants [variation_num - 1]), не з полів задачі — кожна варіація копіює свій власний блок, а не всі
однаковий. Для задачі без variants — rollup зі старих скалярних полів задачі (той самий
фолбек, що й у _scratch_card).
primary_anchor,audience,awareness_stage,messaging_angle,hook_trigger,hook_type,ad_format,notestag_slugs(text[]) — зTaskVariant.tag_slugs; переживає перехід тега зproposedуapprovedбез жодної зміни в цьому масивіdescription(text, BlockNote JSON) — зTaskVariant.description
━━━ ЛЮДИ ━━━ (→ users._id)
assignee_id,task_owner_id(успадковуються із задачі)
━━━ ДАТИ ━━━
deadline_at,in_work_at,done_atlaunched_at— коли test_status → in_testad_id(text) — id кампанії в рекламному кабінеті, який назвала платформа разом із запуском (див. Інтеграційний API); пишеться один раз, Kumquat його не генерує. Те саме поле є на паку (tasks.ad_id) — звіт по паку розкладає його на всі картки.
━━━ ІНШЕ ━━━
schema_version,created_at,updated_at
USERS — колекція users
Локальне дзеркало Clerk (синк через webhook apply_webhook_event); директорні читання йдуть звідси.
_id(text) — Clerk user id (user_...), не ObjectIdemail,name,image_url(text)role(text)groups(text[]) — частини команди, до яких належить людина; одна людина може бути в кількох. Дзеркалиться з ClerkpublicMetadata.groups, як іrole:upsertзамінює документ на кожному вебхуці, тож поле, записане лише в Mongo, зникає. Дошка capacity малює колонку кожному з груп, обраних у фільтріgroups; хто не в жодній групі — колонки не отримує ніколиcreated_at,updated_at
creative_metrics_ts — time-series
Ще не існує. Колекцію ніхто не створює і ніхто в неї не пише: скоркард (
kumquat-creative-metrics) пише тількиcreatives.metricsіcreatives__metrics_runs, історія метрик поки не зберігається взагалі. Форма нижче — це задекларована модельMetricsSnapshot(kumquat_creatives.models), тобто план, а не факт.
Append-only снапшоти метрик (timeField=ts, metaField=meta); останній снапшот денормалізується
в creatives.metrics. Поля: ts, meta ({creative_id, source}), ctr, cpi, ipm,
hook_rate, hold_rate, spend, purchases.
COMMENTS — колекція comments
Коментарі до задач. Узагальнена схема (entity_type + entity_id) замість task_id, щоб інші
сутності могли отримати коментарі без міграції; сьогодні підключено лише task.
entity_type(text) —task(єдине значення поки що)entity_id(→ tasks._id)author_id(→ users._id, Clerk id — рядокuser_..., не ObjectId)body(text) — зберігається нормалізованим: NFKC, без невидимих символів (категоріїCc/Cf/Zl/Zp, окрім\tі\n), прогони з 3+ переводів рядка стиснуті до одного порожнього рядка, краї обрізані. Максимум 2000 символів після нормалізації — NFKC вміє подовжувати рядок (fi→fi), тож ліміт на вхідному запиті сам собою не гарантує розмір збереженогоmentioned_user_ids(→ users._id[]) — Clerk id користувачів, передані фронтендом явно; звалідовані на існування, без автораreactions(map emoji → users._id[]) —{"👍": ["user_a", "user_b"]}. Ключі — закритий набірReactionEmoji(30 емодзі), бо ключ тут це ім’я поля; емодзі без реакторів не лишається порожнім масивом, а видаляється з мапиcreated_atupdated_at(nullable) — проставляється при редагуванні автором;null= ніколи не редагувалиdeleted_at(nullable) — м’яке видалення;null= живий коментар. Документ лишається в колекції разом ізbody, але API віддає видалений коментар із порожнімиbody,mentioned_user_idsіreactionsі не рахується вtask__tasks.comment_count
Відповідей (тредів) немає: коментарі плоскі. GET /tasks/{id}/comments повертає хронологічний
список, включно з видаленими — сортування завжди за created_at, тож редагування не міняє порядок.
AUDIT__LOGS — колекція audit__logs
Єдиний append-only журнал змін: хто, що і коли змінив. Пишеться декоратором @audited на методах
запису репозиторіїв (пакет kumquat-audit), а не викликами по бізнес-логіці.
at(date) — колиaction(text) —task.created,task.status_changed,comment.mention_notified, …entity_type/entity_id(text) —entity_idзавжди рядок: типи id різні по колекціях (ObjectIdу задач і креативів, код продукту вproducts, Clerk id уusers, S3-ключ вassets)actor_id(→ users._id, Clerk id; null для системних дій),actor_kind—user/system/webhook/clisource—api/worker/webhook/cli;request_id— id HTTP-запиту або Celery-таскиchanges(object | null) — застосований$set; на рядку створення це повний документ, що і є якорем для відновлення стану.nullтам, де подія нічого локально не змінила (спроба надіслати DM) — згортка дельт такі рядки пропускаєmetadata(object | null) — решта аргументів методу; тільки BSON-скаляри
Дві колекції згорнуті сюди й більше не пишуться:
| Було | Стало |
|---|---|
task__transitions |
action = "task.status_changed", changes.status = куди, metadata.expected_status = звідки |
mention_notification_logs |
action = "comment.mention_notified", metadata.recipient_id + metadata.status (sent / skipped_no_email), changes = null |
sent означає “спробу зроблено”, а не “Slack підтвердив доставку”: SlackNotifier ковтає помилки
Slack API і лише логує warning.
creatives__metrics_runs — стан між run-ами скоркарду
Один документ на run пакета kumquat-creative-metrics. Не історія метрик — саме стан,
потрібний наступному run-у: гард на обвал кількості рядків порівнюється з останнім
успішним не-dry run-ом, а модальна defn_version по колекції — це те, що перевіряє
doctor. Індекси: finished_at (спадний), run_id.
Поля: run_id, defn_version, wm, dry_run (bool), media_sources, rows,
matched, modified, missing, match_ratio, swept, started_at, finished_at,
ok (bool), error (лише коли ok: false). Dry-run-и не пишуться взагалі.
Індекси і ліміт 64 на колекцію
MongoDB не дає колекції більше 64 індексів, і creatives стоїть рівно на цій межі —
64/64, включно з _id_. Практичний наслідок: нового індексу на creatives додати не можна,
не прибравши інший. Специфікації живуть у indexes.py кожного feature-пакета, композуються в
kumquat_api/indexes.py.
Дві речі, які варто знати перед тим, як щось тут міняти.
ensure_indexes тільки створює, ніколи не видаляє. Перейменоване поле лишає свій індекс на
колекції назавжди, і той тихо тримає слот. Саме так це й зламалось: complexity колись
розділили на дві оцінки — одна sort-опція стала двома, тобто декларований набір виріс з 63 до
64, а забутий complexity_1__id_1 зробив 65. Тому кожне перейменування поля, яке має індекс,
тягне за собою явний dropIndex поруч зі створенням (_drop_legacy_unique_name у
kumquat-tasks, _drop_redundant_metrics_status_index у kumquat-creatives) — щоб кожна база
лікувала себе сама, а не чекала ручного dropIndex в кожному середовищі. Такий дропер живе
рівно доти, доки всі середовища його не проковтнули; після цього його прибирають разом з
одноразовою міграцією, яка перейменувала поле.
Помилка про ліміт вказує не на винного. Mongo репортує CannotCreateIndex на тому індексі,
який трапився останнім у цикл створення, — у тому випадку це був marketing_complexity, який ні
до чого не причетний. Тому створення йде через kumquat_core.db.indexes.create_indexes(): на
коді 67 вона перечитує колекцію і каже, що насправді сталося — ліміт, скільки індексів є проти
скількох задекларовано, і перелік тих, яких у коді вже немає.
Стеля перевіряється тестом (tests/core/test_db_indexes.py), щоб набір не доріс до 64 мовчки.
Підняти число в тесті — це рішення: спершу варто прочитати CREATIVE_INDEX_FIELDS і зняти те,
чого жоден запит не потребує.