עקרונות להוספת פיצ’ר לסטיקי-נוטס — השרת, המסד והחוזים בין השירותים
העמוד הזה הוא חלק מ-עקרונות להוספת פיצ’ר לסטיקי-נוטס, ומחזיק את מה שחי בפייתון ובמונגו, ואת החוזה בין השרת ללקוחות שלו — הדפדפן ושרת ה-MCP. מנוע הרינדור של הפתק — ב-עמוד מנוע המארקדאון, והפתק כווידג’ט בדפדפן — ב-עמוד הממשק.
היעד: בדיוק אחד, ובשכבה טהורה אחת
פתק שייך ליעד אחד בדיוק — קובץ, לוח, קובץ בריפו, או הארכיון. הכלל הזה חי במקום יחיד, sticky_notes_target.build_note_target, וכל מסלולי הכתיבה עוברים דרכו. הוא אינו בדיקה בראוט שאפשר לשכוח: הוא בנאי שמקבל כוונה, בונה את שדות היעד, ומריץ ולידציה לפני ההחזרה — כך שקורא אינו יכול לייצר מסמך לא חוקי.
כשמוסיפים סוג יעד, מוסיפים שורה ל-TARGET_FIELDS — המפה שקובעת אילו שדות מותר לכל סוג לשאת — ולא רשימת ”צירופים אסורים“. הכלל הוא תכונתי: כל שדה שאינו של הסוג שנבחר נפסל, ומי שמוסיף סוג אינו צריך לזכור לעדכן מנייה במקום אחר. הארכיון נוסף בדיוק כך (ארכיון: היעד הרביעי).
חשוב
השכבה הזו טהורה בכוונה — בלי Flask ובלי pymongo — כדי שגם ה-webapp, גם mcp_server וגם services יוכלו לייבא ממנה. אל תוסיפו לה תלות כבדה; ולידציה שחיה רק בצד אחד היא הבטחה שהצד השני מפר בשקט.
המכסה: fail-closed, והפטור-לאדמין שהורג מכסות בשקט
check_note_quota דוחה כשספירת הפתקים נכשלה (existing=None), ולא מניחה אפס. תקרה שנפתחת לרווחה בדיוק כשהמסד מתקשה אינה תקרה.
הפטור לאדמין מסוכן יותר ממה שהוא נראה. אם המשטח החדש נגיש לאדמינים בלבד — כפי שדפדפן הריפו נגיש — אז כל מי שמגיע אליו הוא אדמין, ומכסה שפטורה-לאדמין אינה נאכפת על אף אחד לעולם: קבוע מת שנראה חי. לפני שמעבירים is_admin למכסה, שאלו מה מטרתה. מכסה של צורת-תוכן (למשל ”עשרים פתקים על קובץ = זה כבר עמוד תיעוד“) חלה על כולם, ולכן נקראת עם is_admin=False תמיד. רק מכסת הגנת-משאבים נשארת פטורה.
חשוב
טסט שבודק מכסה חייב לרוץ עם is_admin=False. עם is_admin=True הפטור מקדים כל בדיקה אחרת, והטסט עובר בלי לבדוק כלום — בדיוק המלכודת ”טסט עובר, מכסה שלא מכסה“.
הערה
מגבלה ידועה: הספירה והכתיבה אינן אטומיות. כל התקרות (קובץ בריפו, לוח, משתמש, ארכיון) סופרות ואז כותבות, ולכן שתי בקשות מקבילות שמגיעות כשנותר מקום לאחת יכולות שתיהן לעבור — תקרה של 20 תיתן 21. זה ידוע ומקובל: התקרות הן שמירת צורת-תוכן והגנת-משאבים גסה, לא אילוץ נתונים, וחריגה של פתק אחד אינה מאבדת מידע ואינה פותחת פרצה.
פתרון אמיתי (מונה מוגן-אילוץ, או טרנזקציה) הוא שינוי שצריך לחול על כל התקרות יחד ובמנגנון אחד — לא על אחת מהן. הוספת מונה ליעד אחד בלבד הייתה מוסיפה מצב שני שיכול להיסחף מול המסמכים בפועל, ומשאירה את האחרות כמות שהן.
אינדקס ייחודי: ממוספר, ונבנה לפני שמפילים
כל אינדקס ייחודי חדש עובר דרך המנוע המשותף _ensure_versioned_unique_index: בונים את החדש, מאמתים אותו ב-index_information חוזר, ורק אז מפילים גרסה ישנה. מונגו אינו יודע לשנות שם של אינדקס, ולכן עדכון ”במקום“ מחייב את הסדר ההפוך — ובין ההפלה לבנייה אין ייחודיות כלל, ושתי בקשות מקבילות יכולות להכניס שני ערכים זהים. אז הבנייה החדשה נכשלת, והאכיפה נשארת מושבתת לצמיתות.
לכן השם נושא גרסה (_v1, _v2), ושינוי מפרט מעלה את המספר במקום לערוך את הקיים. הפונקציה מחזירה True רק אחרי הקריאה החוזרת — ערך החזרה של כתיבה אינו אימות.
חשוב
partialFilterExpression הוא מה שמדיר את שאר הסוגים מהאינדקס. אינדקס הלוח מסנן על board_id קיים, אינדקס הריפו על repo_name וגם repo_path קיימים — שני חצאי היעד, כי מסמך שנושא רק אחד מהם אינו יעד ריפו חוקי, ובלי הדרישה הכפולה הוא בכל זאת נכנס לאינדקס תחת שדה חסר. בלי הסינון, פתקים מסוגים אחרים נכנסים עם שדה חסר, חולקים ערך מפתח, ושני פתקים שונים עם אותו שם נדחים ב-E11000. את זה סטאב לא יכול לבדוק — create_index שלו מחזיר None — ולכן בדיקת הייחודיות חייבת לרוץ מול מונגו אמיתי.
אכיפה שנבנית רק בצד אחד היא הבטחה שהצד השני מפר בשקט
הסעיף שמעל עוסק ב**איך** בונים אינדקס אכיפה. זה עוסק ב**מי** בונה אותו, וזה כשל נפרד לגמרי: אינדקס תקין לחלוטין שנבנה רק באחד השירותים.
לפתקים יש היום שני כותבים — הוובאפ ו-mcp_server — ושניהם מחזירים duplicate_title על סמך דחייה של המסד. שירות שאינו בונה את האינדקס עדיין מבטיח את הייחודיות, ופשוט לעולם לא אוכף אותה: אין שגיאה, אין לוג, ושני פתקים עם אותו שם נכתבים. פריסה שבה רק ה-MCP רץ היא בדיוק המקרה הזה.
לכן, כשמוסיפים אינדקס אכיפה: מוסיפים אותו בכל כותב, והמפרט חייב להיות זהה בייט-לבייט בין הבוטסטראפים. מונגו דוחה ב-code 85/86 אינדקס בשם קיים עם מפרט אחר, כלומר סטייה של תו אחד הופכת את הבוטסטראפ השני לכשל שקט לצמיתות — וגרוע מכך, _ensure_versioned_unique_index עלול להיכנס למחזור הפלה-ובנייה, שפותח בדיוק את החלון בלי ייחודיות שכל מנגנון הגרסאות נועד לסגור.
שני כללים נוספים שנגזרים מזה:
זוג דגלים עצמאי לכל אינדקס. דגל משותף נותן לכשל של האחד לחסום את הניסיון החוזר של השני, ולהצלחה של האחד להדליק אכיפה שלא אומתה עבור השני.
אינדקס אכיפה חדש נבנה במסלול הכתיבה, לא בכל קריאה. ההבטחה נאמרת רק שם, ולכן רק היא משלמת על אימותה. זה הכלל לאינדקס שמוסיפים מהיום; אינדקס הלוח הקיים אינו עומד בו —
_ensure_title_indexעדיין נקרא מ-_notes_collב-mcp_server/backend, כלומר גם ממסלולי קריאה. זה חוב קיים שקדם לכלל ולא יושר יחד איתו, כדי לא לערבב שינוי התנהגות בשירות חי עם הוספת יעד. אל תעתיקו ממנו.
הבדיקה שהעניין הזה קיים אינה יכולה להיות סטאב. create_index של סטאב מחזיר None ומרוצה מכל מפרט. הריצו את שני הבוטסטראפים על אותו אוסף במונגו אמיתי, בשני הסדרים, ובדקו ששניהם עוברים ושסט האינדקסים שנוצר זהה.
זהות לעומת נורמליזציה: אותה פונקציה בשני הקצוות
כשמזהה יעד אינו אטומי — למשל נתיב קובץ — הוא חייב לעבור נורמליזציה אחת ואחידה, גם בכתיבה וגם בקריאה, דרך אותה פונקציה. נורמליזציה בצד אחד בלבד היא הכשל השקט הקלאסי: פתק שנכתב בצורה אחת לא יימצא בשאילתה שנבנתה בצורה אחרת — השאילתה רצה, מחזירה אפס, ולא זורקת כלום.
והיעד של הנורמליזציה אינו ”עקביות פנימית“ אלא התכנסות לצורה שכבר קיימת. נתיב פתק ריפו חייב להתכנס לצורה שבה repo_files שומר נתיבים, אחרת גילוי היתומים — שמשווה מול המניפסט הזה — יסמן קובץ קיים כמיותם.
יתומים: זיהוי בקריאה, לא בכתיבה
כשמשטח יכול ”לזוז מתחת לפתק“ (לוח שנמחק, קובץ שנעלם מהעץ), אל תזהו יתומים בסריקה תקופתית ואל תריצו update_many על מסלול קריאה. הפתק ממשיך להתקיים עם הזהות האחרונה הידועה, ומסומן כמיותם בעת הטעינה בלבד. וכשיש שתי רמות של ”נעלם“ (הקובץ נעלם / הריפו כולו נעלם), שתיהן צריכות תצוגה — אחרת הרמה החיצונית ”נעלמת בשקט“, כי אין לה מקום להופיע בו.
וההפרדה הזו אינה פוטרת מ**שער בכתיבה**: זיהוי בקריאה נועד למשטח שזז אחרי שהפתק נוצר, לא לפתק שנוצר על משטח שמעולם לא היה שם. יצירה מאמתת את היעד מול אותו מניפסט שהקריאה משווה אליו — אחרת הפתק נולד יתום, נספר בתקרה, ומופיע רק ברשימת היתומים. כשל בקריאת המניפסט נסגר גם כאן.
חשוב
כשל שאילתה נבדל תמיד מ“אין“. מניפסט שלא נקרא אינו ”אין יתומים“, ורשימת מראות שלא נקראה אינה ”הריפו נעלם“ — שניהם מדווחים unknown, כי קריאה שנכשלה אינה ראיה על מצב העולם.
מצב התזכורת: שדה אחד מתאר, שניים סוטים
לתזכורת יש שני שדות שמתארים את אותה עובדה — status, עמודת מחזור החיים, ו-ack_at, שמסמן שהמשתמש ראה אותה. כל עוד הם נכתבים בנפרד הם נסחפים, וזה בדיוק מה שקרה: reminders_ack כתב ack_at ולא נגע ב-status. תזכורת שנצפתה ונסגרה נשארה pending לנצח, כי לא היה לה מצב סופי לעבור אליו.
הסחיפה לא נראתה בקריאת אף אתר בנפרד, והיא צפה רק בהשוואה בין שני צרכנים: כרטיס הפוש בדשבורד סופר לפי status בלבד ודיווח תזכורות ”בהמתנה“, בזמן שהבועה — שמסננת גם ack_at — לא הציגה כלום. מדידה מול המסד הראתה שזה לא מקרה קצה אלא ההתנהגות היחידה שהייתה: כל המסמכים באוסף היו במצב הזה, כי אין מסלול אחר.
לכן ההגדרה חיה היום במקום אחד, note_reminder_state, והוא טהור מאותה סיבה ש-sticky_notes_target טהור: שלושה מודולים שואלים ”האם התזכורת פעילה“ — הראוטים של הפתקים, הדשבורד, ושולח הפוש — והתשובה חייבת להיות אחת. acknowledge_fields מחזירה את שני השדות, ולכן מי שסוגר תזכורת אינו יכול לכתוב אחד ולשכוח את השני; אין לו את המילון החלקי בכלל.
שתי מלכודות שנגזרות מזה, ושתיהן כבר נשכו:
``status`` לבדו לא סינן כלום. בלי מצב סופי,
status in (pending, snoozed)היה נכון על כל מסמך באוסף — מי שסינן לפיו קיבל את הכול וחשב שסינן. עמודת lifecycle בלי מצב יציאה היא עמודה קבועה.שתי פעולות סותרות על אותה תזכורת. ה-Service Worker הריץ
snoozeומיד אחריוackעל אותה לחיצה, כי מסלול הפתיחה כלל אז גם אתsnooze_10, כפולבק למיפוי כפתורים שגוי. הדחייה נקבעה ובוטלה באותה נשימה, והתזכורת לא חזרה. כשיוצאות שתי קריאות מצב מאותו אירוע, הכריעו ביניהן בקוד — לא בסדר ההגעה. מנובמבר 2026 הפולבק עצמו הוסר, כי מיפוי כזה אינו קיים: דחייה דוחה ובזה הלחיצה נגמרת, בלי פתיחה ובליack(הלחיצה על ההתראה).אישור בלי מועד סגר את התזכורת הלא נכונה.
ackהתאים על(user_id, note_id, ack_at)בלי לומר איזו תזכורת, ו-set_note_reminderעושה upsert על אותו מסמך — כך שהתראה שישבה במגש מאתמול סגרה את התזכורת שנקבעה מחדש למחר. עכשיו ההתראה והחלונית נושאות אתremind_atשל המועד שנורו עליו, הלקוח שולח אותו בגוף בקשת ה-ack, והפילטר תופס רק מסמך שעדיין נושא אותו (note_reminder_state.parse_remind_atהופך אותו למפתח ברזולוציית מילישניות, כמו שה-BSON שומר). השדה אופציונלי: לקוח ישן ששולחnote_idבלבד מאשר בלי קשירה, כמו קודם; מחרוזת שאינה ISO, או מועד שמחוץ לטווח, היא400 invalid_remind_at. תשובת השרת לא השתנתה —{"ok": true}בלבד, בליremind_at. פעולה על ישות שיש לה ”מופעים“ חייבת לנקוב במופע. ומאותו מודול מגיעים גם שדות הכתיבה של הדריכה והדחייה (armed_fields,snoozed_fields): קבוע מצב שמשתנה משנה כך גם מה נקרא וגם מה נכתב.
ומכיוון שמסמכים שנכתבו לפני שהמצב הסופי היה קיים נשארים כפי שהם, הפילטר בודק את שני השדות גם אחרי התיקון, ומיגרציה חד-פעמית מיישרת את הישנים.
וכלל המניפסט שבסעיף היתומים חל גם על מסלולי הבועה: שאילתה שנכשלה עונה 500, לא רשימה ריקה. ok:true, count:0 על מסד שלא נקרא הוא בדיוק ה“אין“ שהכלל אוסר — הבועה אמרה שיש, והחלונית אמרה שאין.
השם של פתק בשורה אחת: פונקציה אחת לפוש, לפעמון ולדשבורד
התראת הפוש של תזכורת, חלונית הפעמון והדשבורד (כרטיס ”הגדרות פוש“, ציר הזמן ו“פתקים אחרונים“, דרך app._dashboard_note_title) מציגים כל פתק בשורה אחת קצרה, וכולם מקבלים אותה מ-sticky_notes_target.note_label: שם הפתק, ובלעדיו השורה הראשונה של התוכן (הראשונה שיש בה טקסט, ברווחים מכווצים), ובלי תוכן — טקסט העוגן. התקרה בתווים, NOTE_LABEL_MAX_CHARS, היא התקרה של שם (MAX_NOTE_TITLE): מה שעומד במקום השם מקבל את הגבול שלו, ושורה אחת יכולה להחזיק את כל הפתק. מה שארוך ממנה נחתך שם ומקבל ”…“ — גם שם ארוך מהתקרה, שנקרא ב-canonical_title_text ולא ב-normalize_note_title, כדי שלא ייחתך בשקט. מה שהמשתמש רואה: התראות ותזכורות (Reminders).
למה פונקציה אחת. עד נובמבר 2026 היו שלושה עותקים — push_api._coerce_preview והפונקציה הפנימית ב-reminders_list לקחו שש מילים מתחילת התוכן, ו-app._extract_note_preview בדשבורד לקח את השורה הראשונה — ושלושתם נכתבו לפני שלפתקים היו שמות, ולכן אף אחד מהם לא הכיר אותם. בדשבורד גם ההיטלה של _lookup_notes_map לא כללה את title. כל מקום חדש שמציג פתק בשורה אחת קורא לאותה פונקציה, ומי ששולף את הפתק בשבילה שולף לפחות את NOTE_LABEL_FIELDS — השדות שהיא קוראת.
בשולח הפוש, הפתק נשלף רק מבין הפתקים של בעל התזכורת (_user_id_variants של ה-user_id שלה), כמו ב-reminders_list — תזכורת פגומה שמצביעה על פתק של משתמש אחר אינה מציגה את שמו — ותזכורת בלי user_id אינה מציגה כלום. וכשל בקריאה אינו מפיל את ההתראה. push_api._reminder_label תופסת כל חריגה, רושמת אותה (push_reminder_label_failed) ומחזירה מחרוזת ריקה — ההתראה יוצאת עם הכותרת בלבד. זה גבול ולא בליעה: התזכורת כבר נתפסה, ו-_send_for_user ו-_send_due_once אינם עוטפים משתמש בודד, כך שחריגה שעולה משם עוצרת את הסבב לכל המשתמשים שאחריו.
הקישור הקבוע של פתק על קובץ: הגרסה האחרונה, בזמן הקריאה
פתק על קובץ מזוהה עם הקובץ לפי השם — file_name ו-scope_id, ששינוי שם מעדכן (sticky_notes_scope.sync_sticky_notes_on_rename, שרץ אחרי שינוי שם בבוט — rename_file משנה את השם בכל הגרסאות — ואחרי שינוי שם של קובץ גדול בוובאפ; בוובאפ, שמירה של קובץ רגיל בשם חדש יוצרת קובץ חדש) — ואילו file_id שבפתק הוא רק הגרסה שעליה הפתק נוצר: כל שמירה של קובץ יוצרת מסמך חדש עם מזהה חדש. note_permalink (/note/<id>) הפנה ל-/md/<file_id> השמור, ולכן לחיצה על התראה או על הפעמון הובילה לגרסה ישנה. היום הוא מפנה לגרסה הפעילה האחרונה של הקובץ, דרך app._note_file_target_id: לפי השם שבפתק, ובפתק ישן בלי file_name — לפי השם של המסמך שב-file_id, תחום למשתמש. פתק על קובץ גדול נפתח ב-file_id השמור, וזה נבדק לפני החיפוש לפי שם: לקובץ גדול אין גרסאות, והחיפוש לפי שם רואה רק את code_snippets, כך שקובץ רגיל באותו שם היה נבחר במקומו. כשאין גרסה פעילה (הקובץ בסל), או כשהמסד נכשל, ההפניה היא גם ל-file_id השמור, כמו קודם, כדי שהקישור לא יישבר. note ו-thread עוברים כמו שהם, ושאר סוגי הפתקים לא השתנו. מה שהמשתמש רואה: התראות ותזכורות (Reminders).
למה בזמן הקריאה ולא בכתיבה. הפתקים אינם מתעדכנים בכל שמירה של קובץ: קבצים נשמרים מכמה מסלולים, ומסלול אחד ששוכח לעדכן היה מחזיר את הבאג בשקט — וזו גם כתיבה למסד על כל שמירה, בשביל פתקים שרובם לא ייפתחו.
”הגרסה האחרונה“ היא הגדרה אחת. _latest_active_version_doc ממיין לפי _LATEST_VERSION_SORT — version, ואז updated_at, ואז _id — ו-api_resolve_file_by_name משתמש באותה פונקציה. version לבדו אינו מכריע: אין אינדקס ייחודי על מספר הגרסה, ושתי שמירות מקבילות יכולות לקבל אותו מספר. עד נובמבר 2026 הפונקציה מיינה לפי version בלבד, והראוט מיין בשלושת המפתחות, כל אחד בעותק משלו. ומי ששואל ”האם זו הגרסה הנוכחית“ — באנר הגרסה (_file_version_context) וההיסטוריה (is_current ב-api_file_history) — משווה _id למסמך שנבחר ולא את המספר, אחרת בתיקו שני מסמכים היו נחשבים נוכחיים בזמן שהקישור הקבוע מפנה רק לאחד (גרסאות: צפייה והשוואה). הבדיקות: tests/test_note_permalink_latest_version.py ו-tests/test_webapp_version_history_times.py.
הדגימה: כשל אינו ”אין“, ו-401 אינו כשל
בלוק הדגימה ב-base.html (initStickyRemindersIndicator) שואל את השרת מתי התזכורת הבאה מבשילה ונרדם עד אז, בין דקה לחצי שעה. וכשיש בועה על המסך השרת עונה לכל היותר חמש דקות — ומוקדם יותר כשתזכורת נוספת מבשילה — כדי שניקוי ממכשיר אחר ומונה שמשתנה ייראו תוך כדי ולא אחרי חצי שעה. הכלל של היתומים — קריאה שנכשלה אינה ראיה על מצב העולם — חל גם עליו.
כל כשל הוא ”לא ידוע“, ולכולם מונה אחד ו-backoff אחד. תשובת שגיאה, שגיאת רשת, JSON פגום ו-timeout נכנסים כולם לאותה פונקציה: המונה עולה, ההמתנה מכפילה את עצמה מדקה עד התקרה, והיא נכתבת לאותו __stickyRemindersBackoffUntil שמשרת את 429 — כך ש-visibilitychange מכבד אותה בלי לדעת עליה. הצלחה מאפסת את המונה; בלי האיפוס, מצב הדיכוי מהתקלה הקודמת היה מרעיל את החלון הבא. הגרסה הקודמת חזרה כל דקה לנצח על כל כשל, ונרדמה חצי שעה על שגיאת רשת — שני הקצוות הלא נכונים, ודווקא כשהשרת נופל. והבועה שכבר הוצגה נשארת: היא המצב האחרון הידוע, וכשל אינו ראיה שהתזכורות נעלמו; רק תשובה שאומרת ”אין“ ו-401 מסירים אותה. 429 אינו כשל ואינו ”אין“: השרת נוקב בעצמו בהמתנה (Retry-After, ולפחות רבע שעה), היא נכתבת לאותו חלון, המונה אינו זז, והבועה נשארת. וכל כשל נרשם בקונסול הדפדפן — מספר הכשל, ההמתנה הבאה והסיבה — כי כשל שקט נראה בדיוק כמו ”אין תזכורות“. ובצד השרת כל 500 במסלולי התזכורות משאיר עקבה בלוג עם ה-traceback (_failed), כי הלקוח הופך אותו ל-backoff שקט וזה האות היחיד, בעוד קלט פסול — דקות שאינן מספר שלם, גוף שאינו אובייקט — הוא 400 בלי לוג, כי טעות של הלקוח אינה כשל של השרת; וכרטיס הדשבורד מציג ”לא ידוע“ ולא 0 כשספירת התזכורות נכשלת, כי מאז שהמונה נכון אפס הוא גם הערך הבריא.
401 עוצר, וההזדמנות הבאה היא הפוקוס הבא. הסשן נגמר, והבועה אינה יכולה להיות נכונה עד שמתחברים מחדש. השרשרת לא דורכת טיימר, וטיימר שכבר היה תלוי מבוטל; visibilitychange מנסה מיד בכל חזרה ללשונית — רצפת הדקה שלו נועדה לשרשרת חיה ואינה חלה במצב עצור — כדי שהתחברות בלשונית אחרת תחיה את הבועה בלי רענון. 401 שמגיע בלחיצה על הבועה עוצר את השרשרת באותו אופן, והחלונית אומרת שההתחברות פגה במקום ”לא ידוע“. דגימה שהייתה באוויר ברגע העצירה — למשל כשה-401 הגיע בלחיצה בזמן דגימה — נזרקת כשהיא חוזרת: תשובה ישנה אינה מחזירה בועה ואינה דורכת טיימר. לכל מנגנון דיכוי צריכה להיות תשובה לשאלה ”מתי הניסיון הבא כן יוצא“ — וזו התשובה כאן.
ה-timeout יושב בלקוח, כי בשרת אין אחד. worker של gevent מודד את השתיקה של ה-worker ולא את אורך הבקשה (Sticky Notes Warmup – פתרון ביצועים משולב), וחיבור half-open — מעבר רשת בנייד, NAT שנפל — אינו מחזיר שום חבילה. fetch בלי סיגנל היה תלוי לנצח, ואיתו השרשרת כולה, כי היא דורכת את הטיימר הבא רק כשהבקשה הנוכחית נגמרת. AbortSignal.timeout נדחה עם TimeoutError ונופל לאותו מסלול כשל; בדפדפן שאינו מכיר אותו התקרה נבנית מ-AbortController — נפילה-לאחור על היעדר יכולת, לא על כשל, ובלי מסלול גרוע יותר: בלי סיגנל בכלל, בקשה תקועה הייתה משאירה את הגארד inFlight דלוק לנצח, והפוקוס לא היה מציל.
וגם החלונית: רשימה שלא נקראה אינה רשימה ריקה. ה-summary אמר שיש תזכורות — זו הסיבה שהבועה קיימת — ולכן ”לא הצלחתי לבדוק“ הוא המצב הנכון, ו“סגור“ משאיר את הבועה.
התוכן: נשמר כמו שנשלח, וה-escape שייך לתצוגה
גוף הפתק נשמר בדיוק כמו שנשלח, ונקרא בדיוק כמו שהוא שמור — אותו כלל של קבצים (ניקוי קוד מודבק (Pasted-code cleanup)). אין בשרת שום שלב שמנקה, מפענח או מקצץ את הגוף: לא html.unescape, לא המרה של CRLF, לא מחיקה של תווי בקרה ולא strip. ה-escape קורה רק בתצוגה, כשהטקסט נכנס ב-textContent (רינדור בתצוגה: innerHTML אף פעם לא על טקסט לא-מהימן).
למה זה כתוב כאן. עד אוקטובר 2026 כל כותב הריץ על הגוף html.unescape — בוובאפ _sanitize_text, וב-MCP _sanitize_note_text — וגם הקוראים פענחו: _coerce_content_from_doc בוובאפ ו-_as_note ב-MCP. הפענוח נוסף ב-#1227 כדי ”לתקן“ פתקים שנשמרו מקודדים אחרי ש-#1048 הריץ html.escape בשמירה. התוצאה: & שמשתמש הקליד נשמר כ-& כבר בשמירה הראשונה, וזה לא נראה עד ריענון, כי הדפדפן הציג את מה שזכר; ©= בתוך כתובת הפך ל-©=, כי html.unescape מפענח גם שמות ישנים בלי נקודה-פסיק; ו-note_str_replace כתב בחזרה את כל הפתק מפוענח, גם את מה שאיש לא ביקש לשנות. ולפני ההסרה נמדד בייצור שאף פתק ואף גרסה בהיסטוריה אינם מכילים ישות שהפענוח בקריאה היה משנה — הרשומה ב-What’s New, 10.10.2026.
כותב חדש של פתק:
רק פענוח של ההעברה.
content_b64הוא base64 של בתי ה-UTF-8, ו-_decode_content_b64מבטל אותו. זה לא שינוי של התוכן.טיפוס בגבול.
contentשאינו מחרוזת נדחה — בוובאפ_content_fieldמחזירהinvalid_content, וב-MCP_note_content_error. בלי הבדיקה,str()היה שומר מילון כטקסט, או שמילון היה נשמר כתת-מסמך בשדה שכל הקוראים מצפים בו למחרוזת.אורך — דחייה, לא חיתוך.
MAX_NOTE_CHARS, נמדד על מה שנשמר.ריקנות — על עותק. ב-MCP גוף שכולו רווחים נדחה ב-
empty_content, אבל הבדיקה רצה על עותק אחריstrip, ומה שנשמר הוא המקור, כולל הרווחים בקצוות.מעבר בפונקציה המשותפת. בוובאפ
_note_content_from_request, וב-MCP_note_content_error— שם גם הגוף ש-note_str_replaceבונה עובר בה, ולא בעותק של הבדיקות. מי שעוקף אותן מוסיף בדיוק את המקום שבו שכבה חדשה תשנה תוכן בשקט.
קורא חדש של פתק מחזיר את הגוף כמו שהוא שמור, ולא מפענח. _coerce_content_from_doc בוובאפ ו-_as_note ב-MCP הם המקומות שכבר עושים את זה.
חיפוש. המחט היא השאילתה אחרי strip בלבד, כמו שהיא, כדי שתתאים לגוף כמו שהוא שמור. שתי דחיות, בשני הצרכנים: מחט עם NUL נענית invalid_query (note_search_needle_error), כי מונגו דוחה $regex כזה בחריגה — עד אוקטובר 2026 הניקוי מחק את ה-NUL, ולכן איש לא ראה את הדחייה. ו-note_search_filter עצמו מעלה ValueError על מחט כזו, כי מחט התוכן יכולה להגיע אליו בנפרד (content_query של backend.search_notes), בלי לעבור בבדיקה של נקודת הכניסה; ומחט ארוכה מ-NOTE_SEARCH_MAX_NEEDLE בוובאפ נענית query_too_long ולא נחתכת — חיתוך שקט היה מחפש קידומת, כלומר עונה על שאלה אחרת.
תשובת הכתיבה ב-MCP מתארת את מה שנשמר ולא את מה שביקשו לשמור, באותו חוזה של הקבצים: note.content_sha256, ו-content_changed רק כשהקריאה כתבה גוף — עדכון של צבע או של שורה מחזיר את ה-hash בלעדיו. בעדכון — מהמסמך ש-find_one_and_update מחזיר עם ReturnDocument.AFTER, ולא מקריאה אחרי הכתיבה, כי הוובאפ כותב לאותם פתקים במקביל; ביצירה — מקריאה חוזרת לפי ה-_id, מה-primary, וכשהיא נכשלת note נושא רק id, בלי hash, ו-content_changed הוא null (_created_note_answer). הפירוט ב-פתקים: note.content_sha256 ו-content_changed.
השומרים. tests/test_content_cleaning_stays_in_the_bot.py נופל אם unescape, או שם של פונקציית ניקוי, חוזר ל-webapp/, ל-mcp_server/, ל-database/ או למודולים שבשורש שפתקים עוברים בהם (GUARDED_ROOT_MODULES). את ההתנהגות עצמה בודקים tests/test_note_content_preserved_mcp.py (ה-MCP דרך mcp.call_tool, ובו test_every_note_write_tool_checks_the_body_in_note_content_error, שעובר על כל כלי כתיבה של פתקים שהשרת רושם), tests/test_note_content_preserved_real_mongo.py (מונגו אמיתי: הוובאפ, החיפוש, התזכורות וכלי הרשימה) ו-tests/test_note_entities_browser.py (כרומיום: שמירה, ריענון ומצב עריכה). שני האחרונים אינם רצים ב-CI, ומי שנוגע בכתיבה או בקריאה של פתקים מריץ אותם לפני PR.
העדפה שיש לה גם מצב ”רק במכשיר זה“
הצד של הנושא הזה בממשק: גודל הטקסט: טוקן אחד, ולמה דווקא בפיקסלים ו-מקור אחד למחלקה, ומקור אחד לסדר.
גופן הפתקים הוא הפיצ’ר השני בפרויקט שבו העדפה יכולה להישמר או לכל המכשירים או למכשיר הנוכחי בלבד; הראשון הוא ערכת הנושא. ההכרעה זהה, ובכוונה — משתמש שלמד את ההתנהגות במקום אחד לא אמור לגלות התנהגות אחרת בשני.
הכלל בשורה אחת: ה-cookie של המכשיר גובר רק כשה-cookie של התחולה אומר device; בכל מקרה אחר ה-DB הוא המקור. במצב device הערך אינו נכתב ל-DB כלל, ולכן טאבלט וטלפון אינם דורסים זה את זה.
שלוש נקודות שנראות קטנות ואינן:
א. ”לא נאמר כלום“ אינו ”נאמר: ברירת מחדל“. _decode_note_fonts מחזירה None על cookie חסר או פגום, ולא dict של ברירת מחדל. ההבחנה היא כל ההכרעה: הראשון נופל ל-DB, השני גובר עליו. פענוח שמחזיר ברירת מחדל בשני המקרים גורם למכשיר ב-device בלי ערך שמור להציג ”הכל רגיל“ במקום את ההעדפה המשותפת.
ב. הערך חייב להיפתר בשרת. ה-cookie הוא httponly, ולכן JS אינו יכול לקרוא אותו כלל. ההעדפה מגיעה לתבניות דרך inject_globals ומשם ל-_note_fonts_head.html, שמחיל אותה בזמן פרסור ה-head. זו גם הסיבה שהמחלקה יושבת על <html> ולא על <body>: בשלב הזה document.body הוא null.
ג. כל ערך cookie חדש חייב להיכנס ל-needs_cookie_update. הרשימה הזו מחליטה אם בכלל נבנית תגובה עם קוקיז. ערך שנשכח בה נבנה, עובר ולידציה, ואז נזרק בחזרה המוקדמת — התגובה 200 והקוקי לא נשלח. זה קרה בפועל ל-ui_note_fonts במצב device, שאינו כותב ל-DB ולכן needs_db_update לא כיסה אותו. הבדיקה שתפסה זאת אסרה על ה-cookie בתגובה עצמה; בדיקה שהסתפקה בסטטוס 200 הייתה עוברת.
גודל הטקסט בעמוד ההגדרות: מה שמסוכן יושב סביב הבורר
הצד של הנושא הזה בממשק: גודל הטקסט: טוקן אחד, ולמה דווקא בפיקסלים ו-מקור אחד למחלקה, ומקור אחד לסדר.
הבורר שבמודאל הלוח נשמר ב-localStorage לכל לוח בנפרד. הבורר שבעמוד ההגדרות אינו יכול לעבוד כך, משתי סיבות שכבר מתועדות למעלה: ה-cookie הוא httponly ולכן JS אינו יכול לקרוא אותו, ו-localStorage אינו עובר מכשיר ולכן ”לכל המכשירים“ לא היה עובד. הערך נפתר בשרת ומגיע מרונדר, בדיוק כמו הבחירה של כתב היד שלצידו.
ההגדרה חלה על שני משטחים יחד, ולכן היא ערך יחיד ולא תת-מסמך; אין כאן את סיבוך המיזוג של note_fonts, שבו כתיבה מלאה הייתה מוחקת שדות שכנים.
בורר התחולה נפרד. note_fonts והגודל מחזיקים כל אחד cookie תחולה משלו, כדי שאפשר יהיה להחזיק גודל למכשיר אחד וכתב יד לכולם. ערבוב ביניהם היה מעביר בשקט גם את כתב היד למכשיר כשמשנים רק את הגודל — כשל שנראה תקין לחלוטין עד שמישהו בודק במכשיר שני.
מה שמסוכן כאן אינו הבורר אלא שלושה מקומות שההעדפה חייבת להירשם בהם, וכולם כבר נשכו בפרויקט הזה:
סכנה
א. הוולידטור של ה-ETag. _note_fonts_head.html מרנדר את ההעדפה לתוך ה-HTML. ערך שאינו נכנס ל-_note_fonts_etag_key אינו משנה את ה-ETag, השרת מחזיר 304, והדפדפן מציג את העמוד הישן עם הגודל הישן. המסלול הזה אינו תלוי בקאש של Redis — הוא רץ לפני בלוק הקאש — ולכן הוא חי בכל תצורה. המפתח נושא היום את שתי ההעדפות, כתב היד והגודל.
ב. ``ETAG_USER_PROJECTION``. מסמך המשתמש נשלף לוולידטורים עם פרויקציה מפורטת. שדה שאינו בה אינו קורס — הוא פשוט חוזר לברירת המחדל, וזה נראה בדיוק כמו ”ההגדרה לא נשמרה“.
ג. ``_etag_needs_user_doc``. יש קיצור דרך שמדלג על שליפת מסמך המשתמש כשכל ההעדפות הוכרעו מה-cookie. העדפה שאינה נספרת שם מחושבת לפי ברירת המחדל דווקא כשהדילוג פועל — כלומר המפתח אינו זז אחרי שינוי ההגדרה, שזה הכשל שסעיף א« נועד למנוע.
ומעבר לשלושתם נשאר הכלל שכבר מתועד למעלה על needs_cookie_update: ערך cookie שנשכח ברשימה נבנה, עובר ולידציה, ואז נזרק בחזרה המוקדמת — התגובה 200 והקוקי לא נשלח. במצב device שום דבר אינו נכתב ל-DB, ולכן needs_db_update אינו מכסה אותו. הבדיקה שתופסת זאת חייבת לטעון על ה-cookie בתגובה עצמה; בדיקה שהסתפקה בסטטוס 200 עוברת על הבאג במלואו.
הערה
הערך מגיע מחוץ לתהליך, ולכן בדיקת הטיפוס קודמת לכל נגיעה בו. גוף ה-JSON יכול להכיל מספר, רשימה, מילון או null, ו-.strip() על כל אחד מהם זורק — כלומר 500 במקום 400, לפני שהוולידציה הספיקה לומר מה לא תקין. הדחייה מוחלטת ולא המרה: טוקן לא מוכר שהיה נשמר כמות שהוא אינו מוחל בדפדפן, והמשתמש היה מקבל ”נשמר“ על גודל שלא ישתנה לעולם.
הרשימה שהשרת מקבל היא מקור שני לצד STICKY_NOTE_FONT_MENUS.settings, ואי אפשר לגזור אחד מהשני בזמן ריצה. בדיקה משווה ביניהם: טוקן שיתווסף לתפריט בלי להתווסף לשרת מפיל שמירה, וטוקן שיוסר מהתפריט ויישאר בשרת נשמר בלי דרך לבחור אותו.
והלוחות נשארים מחוץ לזה. התבנית המשותפת מרנדרת ערך ריק כשהמשטח הוא board, כי שתי החלטות על אותה מחלקה ב-<html> היו נאבקות זו בזו — והמנצחת הייתה זו שרצה אחרונה, כלומר תלויה בסדר ההכללה ולא בכוונה.
צבע: מזהה במסד, hex נגזר, וכינוי במקום מיגרציה
הצבע נשמר כ**מזהה** מהפלטה (yellow, pink_light) ולא כ-hex. השיקול הוא עלות השינוי הבא ולא אסתטיקה: החלפת גוון, או הוספת משמעות לצבע (”דחוף“), נוגעת בשורה אחת ב-NOTE_COLORS ואף פתק אינו צריך מיגרציה. עם hex שמור, כל שינוי כזה הוא כתיבה על כל הפתקים של כל המשתמשים.
וה-``hex`` נגזר, לעולם לא נשמר לצידו. שני שדות שחייבים להישאר מסונכרנים הם שני שדות שיסטו; לכן במסמך יש ערך אחד, ו-note_color_hex גוזרת ממנו את מה שהדפדפן צובע.
חשוב
``note_color_hex`` עוברת דרך ``note_color_id``, ולא במקביל לה. הגרסה הראשונה שאלה ”האם זה מזהה?“ ואם לא — החזירה את ה-hex כמות שהוא, בלי להתייעץ עם טבלת הגוונים. שתי הפונקציות ענו אז תשובות שונות על אותו ערך: אחת אמרה ”זה הצהוב“, השנייה החזירה גוון אחר — כלומר הבורר מסמן עיגול אחד והפתק צבוע בשני.
וזה הסעיף שהכי קל למחוק בטעות, כי עם legacy ריק הפער אינו מתבטא היום באף ערך: הגוון הקנוני של מזהה הוא ממילא מה שהנורמליזציה מחזירה. הוא יתבטא ברגע שמישהו ימלא ``legacy`` — כלומר בדיוק בשינוי הגוון הראשון, ובדיוק במנגנון שנבנה כדי לעשות אותו בזול. זה אותו דפוס שכבר תועד כאן על מפריד הטבלה ועל יצרן ההזחה: שני חלקים שחולקים על אותה שאלה. נתפס בבדיקה ולא בעין.
חשוב
הצהוב הקיים הוא צבע בפלטה, ולא גוון היסטורי של אחר — וזו ההחלטה שהכי קל לטעות בה. #FFFFCC היה ברירת המחדל של הפתקים מאז ומתמיד, ולכן כמעט כל פתק שקיים היום נושא אותו. הפיתוי הוא למפות אותו אל yellow_light שדומה לו, ”כדי שייכנס לפלטה“ — וזה בדיוק מה שנבנה כאן בסבב הראשון. המחיר היה שינוי גוון לכל פתק שקיים בעולם, בפעולה שאיש לא ביקש.
ומה שהצדיק אותו היה שגוי: ההנחה הייתה ש“בלי מיפוי, סינון לפי צבע לא ימצא את הפתקים האלה“. זה אינו נכון — מה שנשמר במסד ומה שמוצג הם שני דברים נפרדים, והטבלה כאן כבר מספיקה כדי שהשאילתה תכסה כל גוון של מזהה. מיגרציה היא אף פעם לא התנאי לכך שחיפוש ימצא משהו.
הפתרון נקי יותר משניהם: לצהוב הקיים עיגול משלו. אף פתק אינו זז, כולם בתוך הפלטה, והוא נשאר ברירת המחדל לפתק חדש. שני הצהובים קרובים ונפרדים, בדיוק כמו כל שני צבעים אחרים.
legacy הוא מה שיחסוך את המיגרציה הבאה: גוונים שמזהה נשא בעבר, שממשיכים להתמפות אליו. הוא ריק היום בכוונה — אין כאן גוון שהוחלף. מי שמחליף גוון בעתיד מוסיף את הישן לשורה שלו, ובזה המיגרציה נגמרת — וזה הדבר שהמזהה קיים בשבילו.
הקלט סובלני, האחסון קנוני
resolve_note_color היא הפונקציה היחידה שמחליטה מה נכתב לשדה, ושלושת הכותבים עוברים דרכה. היא מקבלת מזהה או hex — כי הבורר בממשק כותב מזהה בעוד שסוכן דרך ה-MCP כותב hex, ושניהם צריכים להגיע לאותה צורה מאוחסנת.
הנורמליזציה אינה קוסמטיקה: #FFFFCC, #ffffcc ו-#FFC הם אותו פיקסל בדיוק, ובלי התכנסות לצורה אחת הם שלושה ערכים שונים במסד — כלומר סינון לפי צבע שמחזיר שליש מהתשובה, בלי שגיאה ובלי סימן. זה אותו כלל שכבר מנוסח כאן על נתיב פתק ריפו, ומאותו נימוק.
הערה
הרג’קס ``3,8`` לא צומצם, אף שחמש ושבע ספרות אינן hex חוקי ב-CSS. mcp_server/handlers קיבל אותן, ולכן ערכים כאלה כבר יושבים במסד ובגיבויים; דחייה שלהם עכשיו הייתה מוחקת צבע קיים במקום להשאירו legacy.
חשוב
``resolve_note_color`` מדווחת שלא הצליחה לפענח; מה עושים עם זה הוא החלטה של הקורא. default=None מחזיר "", ושני הקוראים מחליטים אחרת בכוונה:
ה-MCP דוחה —
invalid_colorעם רשימת המזהים התקינים, ביצירה ובעדכון כאחד. סוכן שמקבלokעל צבע שלא הוחל מדווח למשתמש דבר לא נכון; ערוץ הכשל היה קיים ואיש לא קרא בו, וזה בדיוק האישור השקרי שלCRITICAL-PATTERNSK11. והוא גם היחיד שיכול לתקן בניסיון הבא — אם אומרים לו מה חוקי.הוובאפ שומט בעדכון. הבורר שם שולח רק מזהים שעברו בדיקה מול הפלטה בצד הלקוח, ולכן ערך פסול אינו קלט משתמש אלא באג בקוד שלנו — ודריסת הצבע הקיים בברירת מחדל הייתה הופכת אותו מ-no-op לאובדן נתונים.
ההבדל מתועד כדי שלא ייקרא כסתירה, ומי שמיישר אותם צריך להחליט לאיזה כיוון ולמה — לא ”כי הם לא זהים“.
סכנה
”לא ביקשתי צבע“ אינו ”ביקשתי צבע שאינו קיים“, וההבחנה היא כל מה שמפריד בין ולידציה מועילה לבין ולידציה שמעצבנת. None ומחרוזת ריקה (או רווחים בלבד) הם הראשון, ולכן נופלים לברירת המחדל בשקט — הם אינם בקשה שנכשלה. כל ערך אחר שאינו נפתר הוא השני.
והדחייה בעדכון עוצרת את הקריאה כולה ולא רק את הצבע. דילוג על השדה בלבד היה כותב את שאר הקריאה — התוכן מתעדכן והצבע לא — כלומר עדכון שנכתב למחצה, שהוא בדיוק המצב שבו הסוכן מדווח על משהו שקרה רק חלקית. מכוסה בבדיקה, ומוטציה שמחזירה את הדילוג מפילה אותה.
color בתשובת ה-API הוא hex, ולא מה שמאוחסן
התשובה נושאת שני שדות: color תמיד hex חוקי, ו-color_id שהוא המזהה או "" ל-legacy. אותו חוזה בדיוק בשני הסריאלייזרים — הוובאפ וה-MCP — כי אין שום סיבה שסוכן ודפדפן יקבלו צורות שונות לאותה שאלה. color נשאר hex כי הצרכן היחיד שלו הוא style.backgroundColor, ולקוח שנטען מקאש ישן היה מכניס לשם pink_light — ערך CSS לא חוקי, כלומר פתק שקוף על גבי הקוד שמתחתיו. אותה סיבה בדיוק היא שהנפילה ב-note_color_hex היא לגוון ברירת המחדל ולא ל-"".
color_id ריק אינו כשל אלא התשובה הנכונה ל“הצבע הזה אינו בפלטה“, והבורר נפתח בלי סימון. עיגול שהיה מסומן שם היה אומר למשתמש שהפתק בצבע שהוא אינו בו.
ויש שני סריאלייזרים, לא אחד. webapp/sticky_notes_api._as_note_response מגיש לדפדפן, ו-mcp_server/backend._as_note מגיש לסוכן — ושניהם חייבים לגזור את הצבע, לא להעתיק אותו מהמסמך.
סכנה
הסבב הראשון תיקן רק את הראשון, וזה נתפס בריוויו ולא בבדיקה. _as_note העתיק את color גולמי, ולכן מרגע שהמסד החזיק את שתי הצורות זו לצד זו — פתק שהמיגרציה יישרה ופתק שלא — סוכן שקרא שני פתקים באותו צבע בדיוק קיבל שני ערכים שונים: "yellow" מהאחד ו-"#FFFFCC" מהשני. אין לו שום דרך לדעת איזו צורה תגיע.
זה גרוע מחוזה שהשתנה, וההבחנה שווה את המילים: חוזה שהשתנה לומדים פעם אחת, וחוזה שמשתנה לפי מצב הפתק לא לומדים בכלל.
והלקח רחב מהמקרה: ספירת הכותבים אינה ספירת הקוראים. המעבר לפלטה סרק את כל מי שכותב ל-sticky_notes ומצא ארבעה — כולל את מסלול שחזור הגיבוי, שהוא הפחות גלוי שבהם. אבל שינוי בצורת האחסון נוגע בדיוק באותה מידה בכל מי ש**קורא** ממנו, וזה חיפוש אחר לגמרי. _as_note_ref — סריאלייזר פגיעות החיפוש — נבדק גם הוא באותה הזדמנות, ואינו חושף צבע כלל.
הפלטה חיה פעמיים, ובדיקה מצליבה אותן
המקור הוא sticky_notes_target.NOTE_COLORS, אבל sticky-notes.js מחזיק עותק — אי אפשר לגזור אחד מהשני בזמן ריצה בלי לטעון פייתון בדפדפן. זו בדיוק המצוקה שכבר תועדה על הרשימה שהשרת מקבל לצד STICKY_NOTE_FONT_MENUS, והתשובה שם היא התשובה כאן: מקור שני מותר, בתנאי שבדיקה מצליבה ביניהם.
הדרך האחרת — לקרוא את המפה מ-window, כמו ADMONITION_TITLES — נשקלה ונדחתה. היא מחליפה תלות בבדיקה בתלות ב**סדר טעינת תבניות**, וזה בדיוק הכשל שהאזהרה על note_board.html מתעדת: משטח שלא טען את המפה הציג הכל שגוי, בלי שגיאה ובלי לוג.
אזהרה
וההצלבה חייבת לכסות את כל השורה, לא רק את הגוון והתווית. הגרסה הראשונה שלה השוותה שניים מהשלושה ועברה, בזמן שה-JS בנה את מפת ה-hex בלי legacy. התוצאה באותו רגע: גוון שזוהה בשרת ולא בלקוח, כלומר פתק שפתח בורר בלי שום עיגול מסומן. legacy ריק היום, אבל ההצלבה בודקת אותו — כי השדה הזה קיים בדיוק בשביל השינוי הבא, ושומר שלא יכסה אותו היה מגלה את זה רק אז. שומר שבודק חלק מהשורה שומר על חלק מהתשובה.
הצבעים והמדידה
צבעים של רכיבים שמוצגים על הנייר של הפתק, ואיך הם נמדדים: הצבעים והכיול: אותה מלכודת של האלרט, ואותו כלל מובייל ו-הצבעים: משטח קשיח, ומדידה בפיקסלים.
כללי הבורר ב-sticky-notes.css אינם משתמשים ב-var() כלל, ובדיקה אוכפת את זה. זה אינו ”סתם עוד משטח קשיח“: הבורר מציג את הצבעים עצמם, ולכן ערכה שהייתה צובעת את הכרטיס מחדש הייתה משנה בדיוק את הדבר שהמשתמש בא להשוות אליו.
שלושת המספרים שכל צבע בפלטה צריך לעמוד בהם. נמדדו ב-2026-10-27 בכרומיום, בדגימת פיקסלים על הבורר עצמו, על כל צבע ב-NOTE_COLOR_ORDER. tests/test_sticky_color_picker_browser.py מודדת אותם באותה שיטה ואוכפת את הספים — לא את המספרים — ולכן צבע שיתווסף לפלטה נבדק בלי שמישהו יזכור למדוד:
טקסט על ה-swatch — הדיו
#2b3f6aמול גווני הפלטה יוצא 8.14:1 (ורוד, הגרוע) עד 10.29:1 (השנהב), כלומר מעל הסף המחמיר של WCAG AAA. לכן התווית יושבת על הצבע ואינה צריכה רצועה משלה.הגבול של ה-swatch — הוא שמחזיק את הכל. גווני הפלטה בהירים מאוד, ו-
#f0f8ffהוא 1.02:1 מול כרטיס הבורר: בלי גבול הכחול והסגול פשוט נעלמים בו. הגבול הוא פס שיושב חציו על ה-swatch וחציו על הכרטיס, ולכן נמדד לשני הכיוונים, מול הסף 3:1 של WCAG 1.4.11 לרכיבי ממשק. בערך שנבחר,.7, הגרוע הוא 3.93:1 מול ה-swatch (ורוד) ו-4.08:1 מול הכרטיס (השנהב)..6כבר על הסף (3.07 / 3.12), ו-.35, שנראה סביר לחלוטין, נכשל ב-1.84 / 1.76.טבעת הבחירה בדיו מלא: 8.14:1 במקרה הגרוע.
מול הכרטיס, הצבע הבהיר ביותר הוא הגרוע. הגבול הוא דיו שקופה בחלקה מעל הצבע שמתחתיו, ולכן ככל שהצבע בהיר יותר גם הגבול בהיר יותר, וקרוב יותר לכרטיס. השנהב הוא היום הבהיר בפלטה, ולכן הוא שקובע את המספר מול הכרטיס — וצבע בהיר ממנו יוריד אותו שוב.
השנהב (ivory, #fffff0) נוסף אחרי השוואה ידנית על פתקי בדיקה בין גוונים בהירים מהקיימים: על גוונים כהים יותר, רכיבי המארקדאון בפתק — המרקר, ::: details, קוד, ציטוט וקישור — לא הסתדרו, והוא היחיד שעבר את ההשוואה. בבורר: טקסט וטבעת 10.29:1, וגבול 4.41:1 מול ה-swatch ו-4.08:1 מול הכרטיס. רכיבי המארקדאון על השנהב נבדקו בעין ולא נמדדו, ולכן אין לו מספר מרקר כמו לגוונים שבאזהרה למטה. החיפוש בתוך פתק נבדק עליו כמו על כל נייר: שתי בדיקות הדפדפן של הצבעים: משטח קשיח, ומדידה בפיקסלים רצות על כל צבע ב-NOTE_COLOR_ORDER, ועברו על השנהב ב-2026-10-27.
אזהרה
הטוקן היחיד בפתק שתלוי ברקע הוא המרקר: צבע אטום אחד, משותף לכל צבעי הפלטה, שיושב כעת על כל אחד מהם במקום על נייר אחד. כל שאר הצבעים בקובץ שקופים או נגזרים מהדיו (transparent, rgba(0,0,0,…), color-mix(… , transparent)) ולכן מסתגלים לכל גוון מאליהם; --sticky-mark-bg/--sticky-mark-line הם צבעים אטומים שההערה בקוד מתעדת שכוילו מול הנייר הצהוב.
נמדד: מרחק המרקר מהרקע יורד מ-102 בצהוב ל-85 בכתום ול-69 בוורוד — כלומר מתחת ל-76 שאותה הערה כבר פסלה כ“ורוד שנבלע בנייר“. הוא נשאר כמות שהוא בכל הצבעים, בהחלטה מפורשת אחרי בדיקה חזותית של האלמנטים על הפלטה: המדד הוא מרחק RGB אוקלידי, והוא גס במיוחד כשהוא משווה ורוד לוורוד ולא ורוד לצהוב.
מי שיוסיף כאן צבע — או ישנה את גוון הוורוד — מודד את המספר הזה, ואם המרקר נבלע התשובה היא טוקן מרקר פר-צבע ולא כיול מחדש של הערך המשותף. הצהוב הקיים, שכולם כויל מולו, נשאר בפלטה ולכן הכיול המקורי לא איבד את המשטח שלו.
הערה
המודאל נבנה כולו בצמתים, בלי ``innerHTML`` — בשונה ממודאל התזכורת שלצידו. הפטור שהכלל בפתקים נותן לתבנית סטטית לחלוטין תקף גם כאן, אבל docs/webapp/language-icons.rst מנסח את מה שמעליו. הרווח אינו סגנוני: שלד שנבנה בצמתים אפשר לבדוק בלי פרסר HTML, כלומר בלי להוסיף תלות לפרויקט רק כדי שהבדיקה תוכל לרוץ.
ומאותה משפחה: הצבע יושב ב-style של ה-swatch ולא במחלקה. כלל CSS לכל גוון היה מקור שני לאותה שאלה, וצבע חדש היה נוסף לפלטה ויוצא עיגול לבן.
הערה
ומה שהסוכן קורא על הצבע נגזר אף הוא מהפלטה. _build_note_color_doc ב-mcp_server/server בונה את תיאור הפרמטר מ-NOTE_COLORS עצמה, ולא מונה את הצבעים ביד: טקסט מוקלד היה מתיישן בשקט בצבע הבא, והסוכן היה ממשיך לראות רשימה חלקית בלי שאף בדיקה תשים לב.
חשוב
הצבע הנוכחי נקרא מהרשומה (``_getEntry``), לא מהאלמנט. style.backgroundColor מוחזר מדפדפן אמיתי כ-rgb(...) ולא כ-hex, ולכן השוואה מולו הייתה נכשלת תמיד ואף עיגול לא היה מסומן — מצב שנראה תקין לחלוטין. הרשומה היא כבר המקום שבו חיים מצב הביטול וזיכרון הבלוקים הפתוחים, ולכן אין כאן dataset שני שיכול להיסחף.
שני חצאים למיגרציה, ואף אחד מהם אינו מיותר
הקריאה מנורמלת בעצמה — note_color_id מזהה #FFFFCC כ-yellow בכל שליפה — ולכן התצוגה והבורר נכונים גם בלי להריץ דבר, ואף פתק אינו משנה מראה בהרצה. מה שהסקריפט קונה הוא צורת אחסון אחת: מסד שמחזיק חלק מהפתקים כ-#FFFFCC, חלק כ-#ffffcc וחלק כ-yellow מכריח כל שאילתת סינון לכסות את שלוש הצורות, ושכחה של אחת מהן מחזירה חלק מהתשובה בלי שגיאה.
לכן scripts/migrate_note_colors.py מיישר את מה שקיים, והנורמליזציה בקריאה מכסה את מה שנכתב בין ההרצה לפריסה ואת גיבויים שמשוחזרים אחריה. הסקריפט הוא דוח בלבד כברירת מחדל, בתבנית migrate_note_boards.py, והוא אינו תנאי לפריסה.
חשוב
``–normalize-legacy`` הוא דגל נפרד. צבע שאינו בפלטה אינו מוחלף בכוח — זו הדרישה. אבל #AABBCC ו-#aabbcc הם אותו פיקסל ושני ערכים במסד, כלומר אותה בעיית סינון בקטן. הדגל מיישר כתיב בלבד ולעולם לא צבע, וההחלטה להריץ אותו נשארת של מי שמריץ.
וערך שאי אפשר לפענח כלל (None, מספר, זבל) נספר ומדווח ו**אינו נכתב**. שכבת התצוגה כבר מציגה אותו כצהוב; כתיבת הניחוש הזה למסד הייתה הופכת אותו לעובדה ומוחקת את העדות שהיה שם משהו אחר.
סכנה
``distinct`` מפרק מערכים, וזו השרשרת שהופכת את הסקריפט הזה למסוכן. מסמך שבו color הוא מערך אינו פתק חוקי ואף כותב אינו מייצר אחד — אבל הוא יכול לשבת במסד מכתיבה ישירה או מגיבוי פגום, והוא המקרה היחיד שבו הסקריפט עלול להרוס נתונים במקום ליישר אותם. שלוש חוליות, שלושתן מתועדות ב-MongoDB:
distinctמגיש את איברי המערך כערכים נפרדים, ולכן["#FFFFCC", "x"]תורם את המחרוזת"#FFFFCC"לסריקה ונראה בדיוק כמו ערך רגיל.שאילתת שוויון תואמת איבר במערך, ולכן המסמך נספר ונכנס לעדכון.
$setדורס את השדה כולו, ולכן המערך היה מוחלף במחרוזת אחת — כלומר האיברים האחרים נמחקים, בפעולה שכל מטרתה לא לשנות שום צבע בכוח.
ההגנה היא ``$type: ”array“``, והיא היחידה שעובדת. הוא הטיפוס היחיד שמתייחס לשדה עצמו ולא לאיבריו; $type: "string" דווקא אינו מגן, כי על מערך הוא תואם אם איבר אחד מתאים. מסמכים כאלה נספרים ומדווחים בדוח, ולעולם אינם נכתבים.
חשוב
והכשל האמיתי כאן היה בבדיקה, לא בקוד. הדמה של האוסף לא שחזרה את סמנטיקת המערכים של מונגו, ולכן הבאג לא היה יכול להופיע בה כלל — בדיקה ירוקה שאינה מסוגלת להיכשל. הדמה מיישמת היום את שלוש ההתנהגויות במפורש, וכל אחת מהן מצוטטת למקור לצידה.
ואחת מהן נראית חסרת משקל ואינה: מוטציה שמסירה את ”שוויון תופס איבר במערך“ לבדה אינה מפילה כלום, כי ההגנה מסננת את המערך קודם. אבל מוטציה שמסירה את ההגנה ואת השורה הזו יחד — עוברת. כלומר זו החוליה שהופכת את הבדיקה למסוגלת להיכשל, ומחיקתה כ“קוד מת“ הייתה משתיקה אותה בלי שאף בדיקה תרד לאדום. נמדד, לא הונח.
סכנה
``update_many`` מדווח על עבודתו ב-``modified_count`` ואינו זורק כשלא נגע בכלום. סקריפט שסופר את מה שתכנן היה מדפיס ”עודכנו 3“ גם כשאפס נכתבו — האישור השקרי של CRITICAL-PATTERNS K11, שכבר נשך בריפו הזה יותר מפעם אחת. המונה נגזר מתשובת המסד, ופער בין הספירה לכתיבה מדווח ואינו נבלע.
חיפוש בפתקים: למה רג’קס ולא $text
הצד של הנושא הזה בממשק: חיפוש בתוך פתק: מודול הדגשה אחד, ומה שהפתק מוסיף עליו.
החיפוש בוובאפ וב-MCP הוא אותה פונקציית התאמה — sticky_notes_target.note_search_filter. שני הצרכנים נבדלים רק במה שהם מציגים: ה-MCP מחזיר הפניות בלבד, והעמוד מוסיף תצוגה מקדימה של 200 תווים. זו ההפרדה שמונעת את המצב שבו אותה מילה מחזירה קבוצות שונות בשני המקומות.
ומאותה סיבה, הארכיון מוסתר שם ולא אצל כל צרכן. בלי include_archived הפילטר מוסיף את התנאי של active_notes_filter — אותה הגדרה של ”לא בארכיון“ שתקרת המשתמש נספרת לפיה, ולא עותק שלה. הצרכנים רק מעבירים את הבקשה: בוובאפ include_archived=1 ב-query, שהעמוד שולח כשהתיבה ”כלול ארכיון“ מסומנת, וכל ערך אחר מלבד 0 או כלום נדחה ב-invalid_include_archived (bool() על המחרוזת היה הופך "false" לאמת); וב-MCP הפרמטר באותו שם. רק True כולל, גם בפילטר וגם ב-handler של ה-MCP, שנקרא גם בלי pydantic באמצע. שתי התשובות מחזירות את מה שהוחל — include_archived בוובאפ ו-searched_archive ב-MCP. list_board_notes לא השתנה: ההעברה מוחקת את board_id, ולכן פתק בארכיון אינו ברשימת הלוח ממילא.
חשוב
``$text`` נבחן ונפסל, על נתונים אמיתיים. אינדקס טקסט נראה כמו התשובה הנכונה — הוא מדורג, ממוין במסד, ויש עליו אינדקס אמיתי. המדידה על פתקים אמיתיים בעברית הראתה שהוא מפספס כ-18% מההתאמות שהרג’קס מוצא, ובמונחים נפוצים הרבה יותר: חיפוש ”שימוש“ החזיר 2 פתקים מתוך 27 שהרג’קס מצא.
הסיבה אינה באג אלא התנהגות מתועדת: $text מתאים טוקנים שלמים, ו-default_language: "none" מתעד במפורש שהוא מתעלם מגזירת סיומות. בעברית, שבה תחיליות וסופיות נדבקות למילה, ”פתק“ אינו מוצא ”בפתק“. רג’קס תת-מחרוזת מוצא את שניהם.
וזו גם הסיבה שההחלטה הזו מתועדת ולא רק מיושמת: $text יחזור לעלות כהצעה, כי הוא הפתרון המתבקש לכל חיפוש טקסט. מי שמעלה אותו שוב צריך את המספר הזה מולו — ואת העובדה שמונגו מתירה אינדקס טקסט אחד לכל אוסף, כלומר זו החלטה שקשה לחזור ממנה.
הערה
מה שלא נעשה, ובכוונה: נורמליזציה של ניקוד. amir-bug-patterns H4 מתאר שחיפוש שאינו מנרמל ניקוד לא ימצא מסמך שנכתב עם ניקוד. מדידה על הפתקים שבמסד מצאה פתק אחד עם ניקוד אמיתי; שאר ההתאמות בטווח היוניקוד הזה היו מקף עברי ופיסוק, שאינם משפיעים על התאמת מילים.
אבל הסיבה המכריעה אינה העלות. H4 דורש לנרמל את השאילתה ואת התוכן יחד, אחרת הנרמול חד-כיווני — כלומר שדה מאוחסן נוסף ומיגרציה. ו-note_search_filter משותף: נרמול רק בוובאפ היה יוצר בדיוק את הפער שהפונקציה המשותפת קיימת כדי למנוע. אם ניקוד יהפוך לבעיה אמיתית, הוא נפתר שם, לשני הצרכנים יחד.
סכנה
חיתוך התצוגה המקדימה הוא ``$substrCP``, ולעולם לא ``$substrBytes``. אות עברית היא שני בייטים, ואינדקס שנספר בתווים שנכנס לאופרטור שמודד בבייטים נוחת באמצע תו ומפיל את השאילתה (Location28657). זה בדיוק האירוע שקרה בחיפוש הקוד בריפו הזה: השגיאה נבלעה ב-try, מסלול חלופי יקר רץ במקומה, והמשתמש קיבל ”לא נמצאו תוצאות“ על חיפוש שמעולם לא רץ.
ומאותה סיבה, ההדגשה נעשית בצד הלקוח ולא לפי היסטים מהשרת: השרת סופר תווים, מונגו נקודות-קוד, ו-JS יחידות UTF-16. שלוש יחידות לאותו טקסט הן שלוש הזדמנויות לערבב, ומי שמערבב מקבל הדגשה שזזה במילה — או שגיאה. ההתאמה נעשית על ה-200 תווים שכבר הגיעו, ואין מה להעביר.
חשוב
סדר שלבי הצינור אינו קוסמטי. $project שמסיר את גוף הפתק חייב לשבת לפני ה-$sort. בסדר ההפוך מונגו החזירה בפרודקשן שגיאה 292 (QueryExceededMemoryLimitNoDiskUseAllowed), כי $sort מאגר בזיכרון את מה שהוא ממיין; allowDiskUse אינו מציל, כי Atlas מתעלם ממנו בקלאסטרים חינמיים ו-Flex.
וה-$projectמונה את מה שנשאר, ולא את מה שיורד (_SEARCH_HIT_PROJECTION ב-webapp/sticky_notes_api.py). עד אוקטובר 2026 הוא היה {"content": 0}, ובמיקום הנכון — ובכל זאת הגוף הגיע למיון: החרגה שבאה אחרי $addFields לא הורידה אותו. explain מול מונגו 8 על 200 פתקים ארוכים בעברית, בתקרה של 50, הראה totalDataSizeSortedBytesEstimate של 1,792,484 בייטים — 51 גופים שלמים — ו-57,260 בהכללה, עם אותה תוצאה. זה amir-bug-patterns, BY-STACK/mongodb.md דפוס 5, שנותן את הפונקציה הזו כדוגמה. הרשימה היא השדות ש-_as_search_hit קורא, כל TARGET_FIELDS (כדי שיעד שיתווסף יגיע מאליו), והשדות שנגזרו ב-$addFields. בדיקה ב-tests/test_notes_search.py משווה, לכל סוג יעד, פגיעה מהצינור לפגיעה שנבנית מהמסמך המלא, והמדידה עצמה נעולה ב-tests/test_note_archive_mongo.py.
והדגשה חשובה על איך זה נבדק: לא על ערך ההחזרה של פונקציית הבנייה, אלא על הצינור שאתר הקריאה באמת שולח ל-aggregate. אפשר לכתוב בונה נכון לחלוטין ולשכוח לחבר אליו את הראוט — והבדיקה נשארת ירוקה. התקדים הוא tests/test_files_pipelines_drop_heavy_fields_early.py, שנכתב אחרי שבדיוק זה קרה.
חשוב
האינדקס שהחיפוש צריך הוא ``(user_id, updated_at)``, וגילינו אותו במדידה ולא בהיגיון. הדוקסטרינג של note_search_filter טען שנים ש-user_id כפרדיקט ראשון חוסם את הסריקה למכסת המשתמש ”ואינו COLLSCAN“. explain על השאילתה האמיתית הראה שהתוכנית הזוכה בוחרת דווקא ב-updated_desc — כלומר סורקת את הפתקים של כל המשתמשים לפי סדר עדכון. התוכניות שנשענות על תחילית user_id נדחו כולן, כי כל אחת דורשת מיון חוסם. התיאור הישן תיאר את מה שנדחה.
ההסתרה של הארכיון הוסיפה אינדקס משלה, ומאותה סיבה בדיוק — מדידה. עם archived_at: None בפילטר, המתכנן בחר בין user_updated_idx (סדר מוכן, archived_at כשארית) לבין user_archived_idx (גבולות הדוקים ו-SORT חוסם). לשאילתה של מילה נדירה ניצח השני, ומונגו שמרה אותו במטמון התוכניות — ושאילתה של מילה נפוצה שרצה אחריה השתמשה באותה תוכנית וקראה את כל הפתקים הפעילים של המשתמש, במקום לעצור בתקרה. נמדד בהרצה אמיתית עם הפרופיילר: 1000 מסמכים שנקראו במקום 22, בכל הרצה. user_archived_updated_idx — (user_id, archived_at, updated_at) — נותן שוויון, שוויון ואז סדר המיון, ולכן התוכנית קוראת לפי הסדר ונעצרת בתקרה גם מתוך המטמון. חיפוש לפי שם בלבד נשען עליו גם הוא, ועם include_archived התוכנית חוזרת ל-user_updated_idx. נעול ב-tests/test_note_archive_mongo.py, שמריץ את החיפוש האמיתי של ה-MCP.
וכל הוספת אינדקס לפתקים נוגעת בשלושה מקומות: _QUERY_INDEX_SPECS בוובאפ, הרשימה המקבילה ב-mcp_server.backend._notes_coll, ו**קידום** _INDEX_READY_CACHE_KEY. בלי הקידום, כל תהליך שקורא דגל מוכנוּת חי מדלג על הבנייה ליממה שלמה, והאינדקס החדש פשוט לא נבנה — בשקט. tests/test_note_boards_api.py נועל את שני הצדדים יחד, כך שאי אפשר לשנות אחד בלי לגעת בשני.
הערה
סינון הצבע: מיגרציה קודם, ופונקציה אחת שיודעת את התשובה. note_color_query מחזירה {"color": {"$in": [<מזהה>, <רג'קס מעוגן חסר-רישיות>]}}. המזהה תופס את מה שנכתב אחרי המעבר לפלטה, והרג’קס את ה-hex ההיסטורי בכל צורת כתיבה — #FFFFCC, #ffffcc, #FfFfCc, #ffc.
רג’קס ולא רשימת כתיבים, כי רשימה הייתה צריכה למנות את כל צירופי הרישיות של שש ספרות; דגל i אחד עושה את אותה עבודה. $in ולא $or, כי מונגו מתירה רג’קס כערך בתוך $in ואינה מתירה שם ביטוי $regex.
והחצי השני הוא ``scripts/migrate_note_colors.py``, שמיישר את מה שכבר קיים. כשכל הסביבות מיושרות ומאומתות, מוחקים את הרג’קס מ-note_color_query — נקודת הסרה אחת, ואין מקום שני לתקן.
שרשורי תגובות: השרת
הצד של הנושא הזה בממשק: שרשורי תגובות: השרת והממשק בוובאפ.
השרת: הבעלות קודמת לכל, והתשובה מהמסד
כל כתיבה עוברת ב-note_threads.py, הכותב היחיד לאוסף, והראוטים תחת /api/sticky-notes/note/<note_id>/threads ב-webapp/sticky_notes_api.py הם עטיפה דקה מעליו (הרשימה ב-WebApp API Reference). user_id מגיע מה-session בלבד, ו-_owned_note_id בודק שהפתק קיים ושייך למשתמש לפני כל קריאה למודול: פתק של משתמש אחר ופתק שאינו קיים עונים אותו 404, ושום דבר לא נכתב. קודי הסירוב של המודול עוברים כמו שהם, עם הסטטוס מ-_THREAD_ERROR_STATUS.
התשובה לכתיבה נבנית ממה שהמסד החזיר (_thread_written): מצב השרשור אחרי הכתיבה, התגובה כפי שנשמרה, ותקצירי כל השרשורים של הפתק, שנקראים אחרי הכתיבה — כך שהמספר ”שרשור N“ של שרשור חדש מגיע מהמקום היחיד שמחשב אותו. כשל בקריאה החוזרת אינו כשל של הכתיבה, שכבר קרתה: threads: null (או thread: null) אומר ללקוח ”לא ידוע, קרא שוב“.
השם, ראשי התיבות והגוון של הכותב נקבעים בשרת: nt.author_label (אצל המשתמש — first_name מה-session, ו-_THREAD_USER_FALLBACK_NAME כשהוא ריק), _author_initials ו-_author_tone. כך אין ב-JS עותק שני של מיפוי הלקוחות, וגוון שאין לו כלל ב-CSS מקבל את האפור של ”סוכן אחר“.
הבועות מגיעות עם הפתקים. שלושת ראוטי הרשימה — לוח, קובץ וריפו — מצרפים לכל פתק את תקצירי השרשורים שלו (_with_thread_summaries), בשאילתה אחת לכל הרשימה (nt.list_threads_for_notes) ובלי התגובות עצמן. כשל מסד בשאילתה הזו אינו מפיל את הרשימה: הפתקים חוזרים בלי threads ועם threads_unavailable, כדי שהלקוח יבחין בין ”אין שרשורים“ לבין ”לא נטענו“. התגובות נשלפות רק כשפותחים שרשור, שרשור אחד בכל פעם: שרשור יכול להגיע ל-MAX_THREAD_BODY_BYTES, ומספר השרשורים על פתק אינו מוגבל.
מחיקה: delete_note מוחק את השרשורים (nt.delete_note_threads) רק אחרי ש-delete_one של הפתק החזיר deleted_count == 1 — בסדר ההפוך, מחיקה שנכשלה הייתה מוחקת שרשורים של פתק שנשאר. כשל מסד (PyMongoError) במחיקת השרשורים נרשם בלוג ואינו מכשיל את הבקשה: הפתק כבר נמחק, ושרשור יתום אינו מוצג בשום מקום. חריגה מסוג אחר היא באג, ולא ”המסד לא ענה“ — היא עולה ל-except הכללי של הראוט, שעונה 500, למרות שהפתק כבר נמחק.
התקרה של תגובה מגיעה לממשק מהשרת: MAX_COMMENT_CHARS עובר לתבניות כ-max_thread_comment_chars (context processor ב-webapp/app.py), ומשם ל-window.NOTE_THREAD_MAX_CHARS בשלושת העמודים — ולא מוקלד ב-JS.
ארכיון: היעד הרביעי
הצד של הנושא הזה בממשק: ארכיון: המנהל במצב ארכיון, העמוד וחלון השם. ומה המשתמש רואה: ארכיון.
המצב הוא סוג היעד. פתק בארכיון הוא פתק שהיעד שלו archive, ושדה הזיהוי שלו archived_at — הזמן שבו הועבר. אין דגל ”בארכיון“ לצד שדות היעד הקיימים: דגל כזה היה מצב שני שיכול לסתור את הראשון, ופתק שנושא גם board_id וגם דגל היה מחייב הכרעה בכל קורא. לכן TARGET_FIELDS קיבל שורה, ו-build_note_target(archived_at=...) בונה את היעד כמו כל יעד אחר. ומאותה סיבה השם בלוח המקור משתחרר: אינדקסי השם הייחודיים מסוננים על board_id ועל repo_path, וההעברה מוחקת אותם. בארכיון אין אינדקס שם, ושני פתקים עם אותו שם מותרים.
שדה זיהוי שהוא תאריך. כל שדה זיהוי אחר נבדק כמחרוזת לא ריקה; archived_at נבדק כ-datetime (_is_archive_stamp, דרך _IDENTITY_PRESENT). זו אותה הגדרה של השאילתות, {"$type": "date"}: אילו כל ערך לא ריק היה ”ארכיון“, מחרוזת בשדה הייתה הופכת פתק ל“פתק ארכיון“ שאינו ברשימת הארכיון ואינו נספר בתקרה שלו — פתק שאין דרך להגיע אליו. note_target_kind מחזיר את הסוג בלי לזרוק, למי שרק מסתעף לפיו: הקישור הקבוע /note/<id> (webapp/boards_ui.note_permalink) מפנה פתק בארכיון ל-/boards/archive?note=<id>, ומעביר thread הלאה כמו בכל יעד.
שני מסננים, וצורתם נמדדה. active_notes_filter כותב "archived_at": None (חסר או null) ולא $exists: False, ו-archived_notes_filter כותב $type: "date". רק הצורות האלה נענות מהאינדקס user_archived_idx — (user_id, archived_at, _id) — בלי לקרוא אף מסמך: COUNT_SCAN עם אפס מסמכים שנקראו, כשהחלופה $exists: False קוראת כל פתק של המשתמש. ספירת התקרה רצה בכל יצירת פתק, ולכן זה משנה. נמדד ב-explain על מה שהקוד שולח (count_documents נשלח כ-aggregate של $match ואחריו $group), ונעול ב-tests/test_note_archive_mongo.py. האינדקס נוסף בשלושת המקומות שכל אינדקס פתקים נוגע בהם (חיפוש בפתקים: למה רג’קס ולא $text, בסוף הסעיף).
תקרה נפרדת, ותקרת המשתמש סופרת רק פתקים פעילים. MAX_ARCHIVED_NOTES_PER_USER היא תקרה משלה, בנוסף ל-MAX_NOTES_PER_USER, ואדמין פטור ממנה — שתיהן הגנת-משאבים. תקרת המשתמש נספרת דרך active_notes_filter בכל כותב שסופר אותה; מי שמוסיף כותב חדש סופר דרך הפונקציה, ולא {"user_id": ...}. _WRITERS ב-tests/test_note_archive_mongo.py הוא רשימת הכותבים, והטסט מריץ כל אחד מהם מול תקרה מלאה של פתקי ארכיון (נוצר) ומול תקרה מלאה של פתקים פעילים (נדחה).
ההעברה: כתיבה מותנית אחת. POST /api/sticky-notes/note/<id>/archive (archive_note) בודק לפי הסדר: הפתק ובעלותו; ”כבר בארכיון“ (200 עם already_archived, בלי כתיבה — לחיצה כפולה או מכשיר שני אינם שגיאה); השם (resolve_archive_title: פתק בלי שם דורש שם, ושם ארוך מהתקרה נדחה ב-title_too_long ולא נחתך); והתקרה. הכתיבה היא find_one_and_update שהפילטר שלו כולל את active_notes_filter — שתי העברות מקבילות של אותו פתק, ורק אחת כותבת. None מהמסד אינו הצלחה: קוראים שוב ומסווגים — נמחק בינתיים (404), הועבר בבקשה המקבילה (already_archived), או archived_at פגום שאינו תאריך (409 archive_not_applied). העדכון נבנה ב-archive_note_update: $set של archived_at, השם ו-updated_at, ו-$unset של כל שדות היעד האחרים — נגזר מ-TARGET_FIELDS, כך שיעד שיתווסף נמחק בהעברה בלי שמישהו יזכור אותו — ושל שדות המיקום על היעד הקודם (ARCHIVE_DROPPED_PLACEMENT_FIELDS: מצב ההצמדה והעוגן). התשובה נבנית מהמסמך שהכתיבה השאירה (ReturnDocument.AFTER).
השרשורים נסגרים אחרי הכתיבה, והתזכורת לא משתנה. פתק בארכיון אינו ממתין לתגובה, ולכן nt.resolve_open_threads מסמן את השרשורים הפתוחים שלו ”נפתר“ (update_many אחד, עם status פתוח בפילטר) — והם יורדים משורת הפריימר. זה קורה רק אחרי שההעברה נכתבה, וכשל שם (PyMongoError) אינו מבטל אותה: threads_resolved: null אומר ללקוח שהשרשורים לא נסגרו, והכשל נרשם בלוג. התזכורת נשארת פעילה, וההתראה פותחת את /note/<id>, שמפנה לארכיון. המסמך ב-note_reminders עדיין נושא את board_id/file_id מהרגע שהתזכורת נקבעה — תיאור ישן שאינו משמש לניווט, ולכן לא מעודכן.
הרשימה והפתק הבודד. GET /api/sticky-notes/archive מחזיר כרטיסים בלבד — שם, צבע וזמן העברה (_ARCHIVE_CARD_PROJECTION, וסריאלייזר מרשימה מפורשת, _as_archive_card) — מהחדש לישן (ARCHIVE_LIST_SORT, עם _id כשובר שוויון). בלי limit: התשובה היא הרשת כולה, ו-limit בלי דפדוף היה מסתיר מאדמין, שפטור מהתקרה, את הפתקים הישנים בלי שום סימן. העבודה פרופורציונלית לתשובה בדרך אחרת: explain מראה IXSCAN בלי SORT, וכל מסמך שנקרא חוזר. כשל ← 500, ולא רשימה ריקה. GET /api/sticky-notes/archive/<id> מחזיר פתק מלא עם תקצירי השרשורים, ורק פתק שבארכיון.
החיפוש: הארכיון בחוץ, אלא אם ביקשו. פתק בארכיון עזב את המקום שבו עבדו עליו, ולכן שני החיפושים — העמוד וה-MCP — אינם מחזירים אותו בלי בקשה מפורשת. ההסתרה, הפרמטר והאינדקס שהיא דרשה — חיפוש בפתקים: למה רג’קס ולא $text.
השדה בכל מקום שמונה שדות יעד. _as_note_response בוובאפ ו-_NOTE_FIELDS ב-MCP נושאים את archived_at, וגם שתי ההיטלות הצרות של ה-MCP — פגיעת חיפוש (_NOTE_REF_FIELDS) וה-$project של pending_note_threads: בלעדיו note_target_ref היה מסווג פתק ארכיון כ-unknown. בפריימר פתק כזה ”בארכיון“ (_pending_item ב-mcp_server/primer.py).
גיבוי ושחזור. הייצוא ממיר את archived_at ל-ISO כמו שאר התאריכים, והשחזור מחזיר פתק ארכיון לארכיון, עם השם (_restore_archive_note), בענף שקודם לענף הלוח: עד היום פתק בלי לוח ובלי file_name נזרק בשחזור בשקט. כפילות נבדקת לפי גוף זהה בארכיון, ותקרת הארכיון חלה גם בשחזור.
מה לבדוק, ובמה
מודל טהור (יעד, נורמליזציה, מכסה): טסטי יחידה, בלי מסד. כל טסט חדש נבדק במוטציה — ביטול התיקון חייב להפילו.
אינדקס ייחודי ופילטר חלקי: מול מונגו אמיתי בלבד. סטאב מחזיר
Noneמ-create_indexוכל בדיקת כפילות עוברת בו, גם אם האינדקס מעולם לא נוצר.צבע (צבע: מזהה במסד, hex נגזר, וכינוי במקום מיגרציה): הפלטה המוקלדת והנורמליזציה —
tests/test_sticky_notes_target.py; ההצלבה בין הפלטה בפייתון לעותק ב-JS, והבורר עצמו —tests/sticky-notes-target.test.js, ב-node. שניהם רצים ב-CI. המספרים שכל צבע עומד בהם בבורר —tests/test_sticky_color_picker_browser.py, על כל צבע ב-NOTE_COLOR_ORDER, ואינו רץ ב-CI.ארכיון:
tests/test_note_archive.py— השכבה הטהורה והראוטים מול דמה עם הסמנטיקה של מונגו למסננים, ורץ ב-CI.tests/test_note_archive_mongo.py— explain, המרוץ, כל הכותבים, גיבוי ו-MCP מול מונגו אמיתי, ואינו רץ ב-CI.tests/test_note_permalink.py— ענף הארכיון בקישור הקבוע.