עקרונות להוספת פיצ'ר לסטיקי-נוטס ================================== :summary: המלכודות החוזרות של הפתקים הדביקים: יעד יחיד ב-build_note_target, מכסה fail-closed והפטור-לאדמין, flush לפני פעולה הרסנית, אינדקס ממוספר שנבנה לפני שמפילים, נורמליזציה בשני הקצוות, יתומים בקריאה, ועדכון עמוד המשתמש. הפיצ'ר של הפתקים הדביקים צובר משטחים לאורך זמן — קובץ ב-CodeKeeper ← לוח ← רינדור מארקדאון ← קובץ בריפו ממורר. כל תוספת כזו נוטה לגלות מחדש את אותן מלכודות, כי הן אינן בקוד של פיצ'ר בודד אלא בחוזה המשותף. העמוד הזה מרכז אותן, כדי שהפיצ'ר הבא (או סוכן AI שממש אותו) יקרא אותן פעם אחת במקום לגלות אותן שוב בפרודקשן. זהו עמוד **כוונה** — הוא מסביר למה הדברים בנויים כפי שהם. הסמכות היא תמיד הקוד; אם עמוד זה סותר את הקוד, הקוד צודק והסתירה היא ממצא לדיווח. היעד: בדיוק אחד, ובשכבה טהורה אחת --------------------------------- פתק שייך ליעד אחד בדיוק — קובץ, לוח, או קובץ בריפו. הכלל הזה חי במקום יחיד, ``sticky_notes_target.build_note_target``, וכל מסלולי הכתיבה עוברים דרכו. הוא אינו בדיקה בראוט שאפשר לשכוח: הוא בנאי שמקבל כוונה, בונה את שדות היעד, ומריץ ולידציה **לפני** ההחזרה — כך שקורא אינו יכול לייצר מסמך לא חוקי. כשמוסיפים סוג יעד, מוסיפים שורה ל-``TARGET_FIELDS`` — המפה שקובעת אילו שדות מותר לכל סוג לשאת — ולא רשימת "צירופים אסורים". הכלל הוא תכונתי: כל שדה שאינו של הסוג שנבחר נפסל. מי שיוסיף סוג רביעי לא צריך לזכור לעדכן מנייה במקום אחר. .. important:: השכבה הזו טהורה בכוונה — בלי Flask ובלי pymongo — כדי שגם ה-webapp, גם ``mcp_server`` וגם ``services`` יוכלו לייבא ממנה. אל תוסיפו לה תלות כבדה; ולידציה שחיה רק בצד אחד היא הבטחה שהצד השני מפר בשקט. המכסה: ``fail-closed``, והפטור-לאדמין שהורג מכסות בשקט ------------------------------------------------------ ``check_note_quota`` דוחה כשספירת הפתקים נכשלה (``existing=None``), ולא מניחה אפס. תקרה שנפתחת לרווחה בדיוק כשהמסד מתקשה אינה תקרה. הפטור לאדמין מסוכן יותר ממה שהוא נראה. אם המשטח החדש נגיש לאדמינים בלבד — כפי שדפדפן הריפו נגיש — אז **כל** מי שמגיע אליו הוא אדמין, ומכסה שפטורה-לאדמין אינה נאכפת על אף אחד לעולם: קבוע מת שנראה חי. לפני שמעבירים ``is_admin`` למכסה, שאלו מה מטרתה. מכסה של **צורת-תוכן** (למשל "עשרים פתקים על קובץ = זה כבר עמוד תיעוד") חלה על כולם, ולכן נקראת עם ``is_admin=False`` תמיד. רק מכסת **הגנת-משאבים** נשארת פטורה. .. important:: טסט שבודק מכסה חייב לרוץ עם ``is_admin=False``. עם ``is_admin=True`` הפטור מקדים כל בדיקה אחרת, והטסט עובר בלי לבדוק כלום — בדיוק המלכודת "טסט עובר, מכסה שלא מכסה". .. note:: **מגבלה ידועה: הספירה והכתיבה אינן אטומיות.** כל שלוש התקרות (קובץ בריפו, לוח, משתמש) סופרות ואז מוסיפות, ולכן שתי בקשות מקבילות שמגיעות כשנותר מקום לאחת יכולות שתיהן לעבור — תקרה של 20 תיתן 21. זה ידוע ומקובל: התקרות הן שמירת צורת-תוכן והגנת-משאבים גסה, לא אילוץ נתונים, וחריגה של פתק אחד אינה מאבדת מידע ואינה פותחת פרצה. פתרון אמיתי (מונה מוגן-אילוץ, או טרנזקציה) הוא שינוי שצריך לחול על **שלוש** התקרות יחד ובמנגנון אחד — לא על אחת מהן. הוספת מונה ליעד אחד בלבד הייתה מוסיפה מצב שני שיכול להיסחף מול המסמכים בפועל, ומשאירה את שתי האחרות כמות שהן. אינדקס ייחודי: ממוספר, ונבנה לפני שמפילים ----------------------------------------- כל אינדקס ייחודי חדש עובר דרך המנוע המשותף ``_ensure_versioned_unique_index``: בונים את החדש, מאמתים אותו ב-``index_information`` חוזר, **ורק אז** מפילים גרסה ישנה. מונגו אינו יודע לשנות שם של אינדקס, ולכן עדכון "במקום" מחייב את הסדר ההפוך — ובין ההפלה לבנייה אין ייחודיות כלל, ושתי בקשות מקבילות יכולות להכניס שני ערכים זהים. אז הבנייה החדשה נכשלת, והאכיפה נשארת מושבתת לצמיתות. לכן השם נושא גרסה (``_v1``, ``_v2``), ושינוי מפרט מעלה את המספר במקום לערוך את הקיים. הפונקציה מחזירה ``True`` רק אחרי הקריאה החוזרת — ערך החזרה של כתיבה אינו אימות. .. important:: ``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`` על מסלול קריאה. הפתק ממשיך להתקיים עם הזהות האחרונה הידועה, ומסומן כמיותם בעת הטעינה בלבד. וכשיש שתי רמות של "נעלם" (הקובץ נעלם / הריפו כולו נעלם), שתיהן צריכות תצוגה — אחרת הרמה החיצונית "נעלמת בשקט", כי אין לה מקום להופיע בו. וההפרדה הזו אינה פוטרת מ**שער בכתיבה**: זיהוי בקריאה נועד למשטח שזז אחרי שהפתק נוצר, לא לפתק שנוצר על משטח שמעולם לא היה שם. יצירה מאמתת את היעד מול אותו מניפסט שהקריאה משווה אליו — אחרת הפתק נולד יתום, נספר בתקרה, ומופיע רק ברשימת היתומים. כשל בקריאת המניפסט נסגר גם כאן. .. important:: כשל שאילתה נבדל תמיד מ"אין". מניפסט שלא נקרא אינו "אין יתומים", ורשימת מראות שלא נקראה אינה "הריפו נעלם" — שניהם מדווחים ``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`` כפולבק למיפוי כפתורים שגוי. הדחייה נקבעה ובוטלה באותה נשימה, והתזכורת לא חזרה. כשיוצאות שתי קריאות מצב מאותו אירוע, הכריעו ביניהן בקוד — לא בסדר ההגעה. - **אישור בלי מועד סגר את התזכורת הלא נכונה.** ``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`` על מסד שלא נקרא הוא בדיוק ה"אין" שהכלל אוסר — הבועה אמרה שיש, והחלונית אמרה שאין. הדגימה: כשל אינו "אין", ו-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 ולא את אורך הבקשה (:doc:`/performance-sticky-notes`), וחיבור half-open — מעבר רשת בנייד, NAT שנפל — אינו מחזיר שום חבילה. ``fetch`` בלי סיגנל היה תלוי לנצח, ואיתו השרשרת כולה, כי היא דורכת את הטיימר הבא רק כשהבקשה הנוכחית נגמרת. ``AbortSignal.timeout`` נדחה עם ``TimeoutError`` ונופל לאותו מסלול כשל; בדפדפן שאינו מכיר אותו התקרה נבנית מ-``AbortController`` — נפילה-לאחור על היעדר יכולת, לא על כשל, ובלי מסלול גרוע יותר: בלי סיגנל בכלל, בקשה תקועה הייתה משאירה את הגארד ``inFlight`` דלוק לנצח, והפוקוס לא היה מציל. וגם החלונית: רשימה שלא נקראה אינה רשימה ריקה. ה-summary אמר שיש תזכורות — זו הסיבה שהבועה קיימת — ולכן "לא הצלחתי לבדוק" הוא המצב הנכון, ו"סגור" משאיר את הבועה. רינדור בתצוגה: ``innerHTML`` אף פעם לא על טקסט לא-מהימן ------------------------------------------------------- **טקסט של משתמש או של סוכן לעולם אינו נכתב דרך HTML גולמי.** כל צומת שנושא תוכן כזה נבנה עם ``createElement`` ו-``textContent``, ותכונות של קישור נכתבות עם ``setAttribute`` אחרי אימות סכימה. לכן זריקת ``