رفتن به محتوا

ذخیره‌سازی و پوستهٔ برنامه

کجا چه چیزی ذخیره می‌شود، کار چطور گم نمی‌شود، پروندهٔ .darz چیست، و پوستهٔ برنامه چطور چیده شده. ماندگاری در نسل پنجم (فاز ۱) از نو نوشته شد و پایگاهِ مرورگر در فاز ۹ لایهٔ دادهٔ apps/web/src/data/ شد.

لایهٔ داده در apps/web/src/data/ است — جدول‌ها، مخزن‌ها، پاکتِ { schemaVersion, data }، ارتقای پایگاه، versionchange/blocked، پشتیبانِ کامل و دفترِ رویداد: data-layer.md. Dexie بیرون از آن پوشه وارد نمی‌شود (لینت).

مرز در نوشتن هم هست. putProject پیش از نوشتن checkProject ِ هسته را می‌زند؛ پروژهٔ نامعتبرِ حافظه دیگر نوشته نمی‌شود تا روزی باز نشود. listProjects سلامتِ هر ردیف را با loadProject می‌سنجد: ردیفِ خراب یا ساخته‌شده با نسخهٔ تازه‌تر روی خانه کارتِ آسیب‌دیده («آسیب دیده — باز نمی‌شود» یا «ساخته‌شده با نسخهٔ تازه‌ترِ درزساز») با علت («جزئیات») و دانلود خام (downloadRawProjectgetRawProject) می‌گیرد.

کاری که گم نمی‌شود — lib/persistence/

Section titled “کاری که گم نمی‌شود — lib/persistence/”
پرونده کار
autosave-coordinator.ts صفِ ذخیره: فقط آخرین حالت، یکی‌یکی، تلاشِ دوباره با پس‌روی (۱، ۴، ۱۵ ثانیه) برای خطای گذرا
autosave.ts سیم‌کشی: هر ویرایش پس از ۸۰۰ms؛ پیاده شدن، پنهان شدنِ زبانه و pagehide همان لحظه تخلیه می‌کنند
workspace.ts کانالِ BroadcastChannel و قفلِ هر پروژه با navigator.locks
file-link.ts پروندهٔ متصل: دستگیره در پایگاه، اجازه فقط با کلیک
import-project.ts آوردنِ پرونده‌ای که همان شناسه در مرورگر دارد: نسخهٔ تازه / جایگزین / لغو
home-data.ts دادهٔ خانه در تکهٔ تنبل، با مهاجرتِ یک‌بارهٔ نسل قبل
rescue.ts نجات در صفحهٔ خطا: پروژهٔ مسیرِ جاری، به .darz
backup-actions.ts «پرونده ▸ پشتیبان کامل / بازگرداندن از پشتیبان…»: دانلود، انتخابِ پرونده (تأیید در RestoreConfirm)، بارگذاریِ دوباره پس از بازگردانی
restore-request.ts پرونده‌ای که برای بازگردانی برگزیده شده و هنوز تأیید نشده (useRestoreRequest، بی Dexie)
assets.ts تصویرِ پروژه در رابط: openStoredProject، keepImage، useAssetUrl، projectAssets — هر تصویر یک data URL ِ یک‌باره در حافظه؛ «تصویرهای بی‌استفاده» (countUnusedImages، freeUnusedImages)
raw-download.ts دانلود خام (downloadRawProject): ردیف همان‌طور که نشسته، بی سنجش
  • بارگذاری با loadIntoEditor (state/store.ts) — تنها راهِ نشاندنِ پروژه: کاتالوگ، دیوار، انتخاب و تاریخچه با هم. project === loaded یعنی چیزی برای ذخیره نیست؛ ذخیرهٔ خودکار بارگذاری را از ویرایش با همین تشخیص می‌دهد.
  • meta.updatedAt با هر ویرایشِ رو به جلو (edit، editLive، replaceProject) زمانِ همان لحظه (ISO ِ محلی با منطقه، nowIso؛ تا دقیقه) می‌گیرد، پس .darz و گزارش همان را می‌خوانند. واگرد دقیقاً حالتِ پیشین را برمی‌گرداند.
  • ذخیره نشد با علت: سهمیهٔ پر («ذخیره در پرونده»، «پاک کردن رندرهای ذخیره‌شده» — که رندر را از سند برمی‌دارد؛ بایتش فقط با «تنظیمات ▸ تصویرهای بی‌استفاده» آزاد می‌شود، چون تصویر خودکار پاک نمی‌شود)، پروژهٔ نامعتبر («واگردِ آخرین تغییر»)، یا خطای ناشناخته («دوباره») — در پاپ‌اورِ نشانِ ذخیره در نوار بالا (app/SaveBadge.tsx). پروندهٔ متصلِ بی‌اجازه ذخیره را «نشد» نمی‌کند؛ «پرونده: X — نیاز به اجازه» جدا گفته می‌شود.

ProjectSession (ویرایشگر، هزینه و کارگاه زیرِ #/p/:id…) قفلِ darzsaz:project:<id> را می‌خواهد. زبانه‌ای که قفل را دارد ویرایش و ذخیره می‌کند؛ بقیه readOnly اند، نوارِ «این پروژه در زبانهٔ دیگری باز است» می‌بینند و با هر «ذخیره شد» ِ کانال از پایگاه از نو می‌خوانند. «همین‌جا ادامه بده» اول از دارندهٔ قفل می‌خواهد صفِ ذخیره‌اش را تخلیه و قفل را رها کند (handover)؛ اگر در ۱٫۵ ثانیه جواب نداد، قفل با steal گرفته می‌شود. زبانه‌ای که قفل را از دست داد در صف می‌ماند و پیش از ویرایش‌پذیر شدن از پایگاه می‌خواند. مرورگرِ بی navigator.locks همان رفتارِ پیشین را دارد.

پنجرهٔ دوم — کانالِ جدا روی همان قفل

Section titled “پنجرهٔ دوم — کانالِ جدا روی همان قفل”

پنجرهٔ پیرو (#/p/:id/view، فاز ۸) قفل نمی‌خواهد و در پایگاه نه می‌نویسد نه می‌خواند: از ProjectGate/ProjectSession نمی‌گذرد، ذخیرهٔ خودکار و Worker ِ چیدمان ندارد، و هر چه نشان می‌دهد از کانال آمده است. نویسندهٔ کانال دارندهٔ همین قفل است (useWorkspaceWriter در ProjectSession، فقط در حالتِ held). کانال BroadcastChannel ِ darzsaz-view:<id> است، نه darzsaz-workspace: عکسِ پروژه در هر فریم نباید به هر زبانهٔ باز ِ برنامه برسد. پیرو پیش از «ویرایشگر بسته شد» می‌پرسد قفلِ projectLockName(id) هنوز گرفته است یا نه (navigator.locks.query) — زبانهٔ پنهان ضربانِ کُند دارد ولی قفلش را نگه داشته. جزئیاتِ پیام‌ها: docs/reference/editor.md «پنجرهٔ دوم».

هر ۲۰ ذخیره یا ۵ دقیقه، و پیش از جایگزینیِ پروژه با پرونده (before-replace) و پیش از برگرداندنِ نسخه (before-restore). پنجرهٔ تاریخچه زیرِ قدم‌های واگرد فهرستشان را با علت دارد؛ «برگرداندن» خودش یک قدم واگرد است.

ماندگاری — چرا لازم است

Section titled “ماندگاری — چرا لازم است”

پس از اولین ذخیرهٔ موفق و در هر شکستِ سهمیه checkStorageHealth (lib/storage-health.ts) اجرا می‌شود و navigator.storage.persist() را می‌خواهد — در هر بارگذاریِ صفحه حداکثر یک بار، چون requestPersistence پاسخ را نگه می‌دارد. بی آن، مبدأ در حالت best-effort است:

  • زیر فشار فضا، مرورگر IndexedDB را بی‌اطلاع پاک می‌کند.
  • سافاری (ITP) ذخیره‌سازیِ نوشتنیِ اسکریپت را پس از هفت روز بی‌تعامل با سایت پاک می‌کند. یعنی کاربری که آشپزخانه‌ای طراحی کرد و یک هفته برنگشت، اگر «ذخیره در پرونده» نزده باشد همه‌چیز را از دست می‌دهد.

چرا در اولین ذخیره و نه در بار اول صفحه: کروم persist() را بدون نشانهٔ تعامل رد می‌کند و چیزی هم نمی‌گوید. اولین ذخیرهٔ خودکار یعنی کاربر واقعاً چیزی ساخته.

اگر مرورگر رد کرد، یک بار (در هر مرورگر، با نشان در settings) گفته می‌شود، با دکمهٔ «ذخیره در پرونده». سهمیهٔ بالای ۸۰٪ هم هشدار می‌گیرد — آن یکی هر بار، چون فوری است و با پاک‌کردن چند پروژه برطرف می‌شود.

اعلان‌ها بیست ثانیه می‌مانند، نه برای همیشه: اعلانِ دائمی روی نوار تبِ پایینِ گوشی می‌نشیند و کلیک را می‌گیرد — هشداری که خودش کار را می‌بندد، از خطری که دربارهٔ آن هشدار می‌دهد بدتر است. اعلانِ «نسخهٔ تازه آماده است» هم بیست ثانیه است و ردیفِ ماندگارش در منوی «کمک».

  • پایگاه نسل قبل (darzsaz / خانهٔ current) یک بار با migrateLegacy (data/legacy.ts) در بارگذاریِ خانه به projects می‌آید و نشان legacyMigrated می‌خورد. شکستِ نوشتن نشان نمی‌گذارد (بارِ بعد دوباره)، و نبودنِ پایگاهِ قدیمی پایگاهی خالی به جایش نمی‌سازد.
  • مهاجرت اسکیمای پایگاه: version(n) خودِ Dexie در data/database.ts؛ نسخهٔ فعلی ۵ (data-layer.md).

در هسته: packages/core/src/io/darz.ts (packDarz(project, { app, thumbnail?, assets? })، unpackDarz، unpackDarzWithAssets، checkDarzSize، DARZ_LIMITS، darzFileName) — وب و خط فرمان هر دو از همین (فاز ۹٫۴). هسته نسخهٔ برنامه را نمی‌داند و بسته‌بند app را می‌دهد.

zip (fflate) با project.json (همان saveProject — که پیش از نوشتن می‌سنجد)، manifest.json (format: 'darz', formatVersion: 2, نسخهٔ برنامه، زمان)، thumbnail.svg (فقط وقتی بندانگشتی داده شود؛ نجاتِ صفحهٔ خطا بی آن می‌بندد) و assets/<id>.<png|jpg|webp> — عکس و رندری که سند فقط شناسه‌شان را دارد (۹.۳؛ بی فشرده‌سازیِ دوباره). بستهٔ قالبِ ۱ (تصویر درونِ project.json ِ نسخهٔ ۲) هنوز باز می‌شود.

تصویرهای بسته از projectAssets (lib/persistence/assets.ts) می‌آیند — «ذخیره در پرونده»، پروندهٔ متصل و نجات هر سه — یعنی حافظهٔ نوشته‌نشده و جدولِ assets. حذفِ پروژه تصویری از آن جدول برنمی‌دارد (تصمیمِ مالک؛ data-layer.md)، پس .darz ِ پروژهٔ دیگری با همان تصویرها پس از حذف هم کامل است؛ تصویرِ بی‌استفاده فقط با «تنظیمات ▸ تصویرهای بی‌استفاده ▸ آزاد کردن…» و تأیید می‌رود.

باز کردن مرز است (unpackDarz):

  • پرونده بیش از ۱۲۸ مگابایت پیش از خواندن رد می‌شود؛ هر عضوی که بیش از ۲۵۶ مگابایت اعلام کند پیش از تخصیص (zip ِ چند کیلوبایتی که «دو گیگابایت» بگوید، زبانه را می‌کُشت).
  • فقط project.json، manifest.json و assets/* باز می‌شوند؛ عضوِ دیگر خوانده نمی‌شود. عضوِ تصویری که محتوایش با شناسهٔ نامش نمی‌خواند کنار می‌رود: شناسه از محتواست و یک مرورگر تصویرِ همهٔ پروژه‌ها را با همان شناسه نگه می‌دارد (unpackDarzWithAssets).
  • manifest.json با formatVersion ِ تازه‌تر darz/newer-format است، نه پروژهٔ خراب.
  • عکسِ دیوار و رندرِ پروندهٔ ۱ و ۲ فقط data URL ِ PNG، JPEG یا WebP (سندِ ۳ فقط assetId)؛ رشته‌ها و شمارِ رندر سقف دارند (LIMITS در packages/core/src/schema/common.tscatalogOverrides اسکیمای کامل دارد و کاتالوگش در باز کردن ساخته می‌شود — پرونده‌ای که باز شد، ویرایش‌شدنی است.

⚠️ unpackDarz عمداً thumbnail.svg را نمی‌خواند؛ بندانگشتی از پروژه از نو ساخته می‌شود. خواندنش یعنی XSS: پروندهٔ .darz از واتساپ می‌آید، SVG می‌تواند اسکریپت داشته باشد، و ProjectCards.tsx آن را با dangerouslySetInnerHTML نشان می‌دهد.

File System Access (کروم/اِج دسکتاپ): «ذخیره در پرونده» یک بار مسیر می‌گیرد (pickDarzFile)، دستگیره در fileHandles می‌نشیند و ذخیرهٔ خودکار همان‌جا هم می‌نویسد. مرورگر پس از بارگذاریِ دوباره اجازه را پس می‌گیرد؛ نوار بالا «نیاز به اجازه» می‌گوید و همان دکمه اجازه می‌خواهد. بدون File System Access، دانلود معمولی.

  • CSP در index.html ِ بیلد (vite.config.ts، پس از <meta charset>): اسکریپت فقط از خودِ برنامه و اسکریپتِ درون‌خطیِ پوسته با هشِ sha256 ِ خودش (نه unsafe-inline)، شبکه فقط به خودش و آینه‌های نرخ دلار، بی unsafe-eval (zod بی JIT). vite dev بی آن.
  • قابِ چاپ sandbox="allow-same-origin allow-modals" — بی اسکریپت؛ پیش‌نمای مرکز چاپ sandbox="".
  • window.__darzsazOpen (خانه ← ویرایشگر؛ darzsaz render، آزمون — app/automation.tswindow.__darzsazLoad (ویرایشگر؛ ابزارِ عکس‌برداری scripts/orbit.mjs، بنچمارک) و window.__darzsazRender (darzsaz render، آزمون) فقط وقتی navigator.webdriver — هیچ اسکریپتی در صفحه نباید پروژه را جایگزین کند یا کارت گرافیک را به کار بگیرد.
  • unhandledrejection و error ِ بی‌صاحب اعلان می‌شوند (lib/global-errors.ts).
#/ خانه: پروژه‌های اخیر، تازه، باز کردن، الگوها، نمونه
#/p/:id ویرایشگر
#/p/:id/cost داشبورد هزینه
#/p/:id/workshop حالت کارگاه
#/p/:id/view?pane=3d|2d|plan پنجرهٔ پیرو (فقط‌دیدنی، از کانال؛ بی پایگاه)
#/share پروندهٔ فرستاده‌شده از واتساپ
#/help راهنما، نقشهٔ کلیدها، دربارهٔ
هر چیز دیگر «این نشانی در درزساز نیست» — نه رفتنِ بی‌صدا به خانه

سه مسیرِ پروژه (#/p/:id، …/cost، …/workshop؛ نه …/view) از useProject می‌خوانند و ProjectGate حالت‌ها را از کدِ خطا نشان می‌دهد: اسکلتِ بارگذاری، آماده (با ProjectSession)، «پروژه در این مرورگر نیست»، «با نسخهٔ تازه‌ترِ درزساز ساخته شده» و «آسیب دیده» (هر دو با دانلود خام)، و «پایگاه مرورگر باز نشد» با متن خطا. خانه هم بارگذاری، خالی و خطای پایگاه را جدا دارد.

  • App.tsx مسیرها را با wouter روی hash می‌سازد؛ اگر همان پروژه در فروشگاه باشد دست نمی‌خورد تا تاریخچه بماند.
  • باز کردن پرونده یک مسیر دارد (app/useOpenProjectFile.tsx) برای خانه، صفحهٔ «فرستاده‌شده» و منوی ویرایشگر.
  • فرمان‌ها یک رجیستری‌اند (REGISTRY در app/registry.ts: برچسب، گروهِ منو، لایه، کلید)؛ app/useCommands.ts با commandOf فهرستِ فرمانِ ویرایشگر را از آن می‌سازد و منوها (TopBar)، پالت فرمان (CommandPalette، CommandList ِ کیت) و میان‌برها (useShortcuts، فقط لایهٔ app) همه از همان می‌خوانند؛ میان‌برِ تازه = فیلدِ shortcut ِ مدخلِ رجیستری، و نقشهٔ کلیدهای راهنما (keymap.ts) از همان رجیستری ساخته می‌شود. میان‌بری که در رجیستری هست ولی در useCommands فرمانی ندارد، کار نمی‌کند — Ctrl+K از فاز ۳ تا گرفتنِ فرمانِ palette همین‌طور بود.
  • کلید با shortcutKey (lib/keys.ts) خوانده می‌شود، نه e.key. با چیدمانِ فارسی e.key ِ کلید K «ن» است و رقم ردیف بالا «۱»؛ shortcutKey حرف/رقمِ لاتینِ چیدمان را باور می‌کند و وگرنه جای فیزیکی (e.code) را. Shift برای حرف، رقم و کلیدِ نام‌دار (Enter، جهت‌نما) سنجیده می‌شود (matchesChord)؛ نشانه Shift را در خودش دارد: «?» خودش Shift+/ است — مگر نشانه‌ای که رجیستری با Shift نوشته (Shift+\).
  • وضعیت رابط (state/ui.ts: نما، چیدمانِ پنل‌ها برای هر اندازهٔ صفحه، پوسته، زبان، روکش‌های بوم) جدا از سند است و در localStorage می‌ماند؛ وضعیت ذخیره در state/save-status.ts.
  • واگرد تراکنشی: edit(label, recipe, { coalesce }) — ویرایش‌های پیاپی با یک کلید در پنجرهٔ لغزندهٔ ۸۰۰ms یک قدم می‌شوند (COALESCE_MS)؛ هر قدم نام و زمان دارد و پنل تاریخچه (HistoryPanel) با jump(n) به آن می‌رود.
  • خط لولهٔ چیدمان (lib/derive-client.ts): مهلت ← Worker ِ تازه در درخواستِ بعد (نه نخ اصلی برای همیشه)؛ سه افتادنِ پیاپیِ Worker ← «کندتر» در نوار پایین با «دوباره».
  • واحد و DOM: db.test.ts و data-layer.test.ts (fake-indexeddb: ارتقا، اتصال، پشتیبان، دفترِ رویداد)، db-connection-bar.dom.test.tsx، core/test/darz.test.ts، thumbnail.test.ts، history.test.ts (زمان تزریقی clock.nowload-into-editor.test.ts، autosave-coordinator.test.ts (ساعتِ دستی)، project-lock.test.ts (Web Locks ِ ساختگی)، file-link.test.ts، import-project.test.ts، save-problem.dom.test.tsx، app-crash.dom.test.tsx، history-panel.dom.test.tsx، derive-client.test.ts، home.dom.test.tsx، project-gate.dom.test.tsx، workspace-channel.test.ts (پیامِ نامعتبر، وصله در فریم، بسته پس از ۳ ثانیه، نویسنده و پیرو روی فروشگاه)، viewer-page.dom.test.tsx، assets-repo.test.ts و export-images.test.ts (تصویر پس از حذفِ پروژه در .darz، نجات، پشتیبان و جلدِ چاپ)، print-share.dom.test.tsx، unused-images.dom.test.tsx.
  • Playwright: e2e/work-kept.spec.ts (ویرایش و رفتن در میانهٔ مکث، دو زبانه، پایگاهِ مسدود)، e2e/security.spec.ts (CSP روی همهٔ مسیرها، قابِ چاپ)، e2e/persist.spec.ts، e2e/second-window.spec.ts (پیرو: صفر تراکنشِ نوشتنی، «بسته شد» پس از بستنِ ویرایشگر). نگهبانِ e2e/fixtures.ts در هر آزمون pageerror و نقضِ CSP را صفر می‌خواهد.
  • vite-plugin-pwa (Workbox) در apps/web/vite.config.ts: registerType: 'prompt' — نسخهٔ تازه تا کاربر «بارگذاری دوباره» را نزند فعال نمی‌شود (src/lib/pwa.ts، src/lib/app-update.ts). خروجیِ build (js، css، html، svg، png، woff2، webmanifest؛ هر پرونده تا ۶ مگابایت) پیش‌کش می‌شود؛ tgju.org همیشه از شبکه. مانیفستِ هر زبان (manifest.<زبان>.webmanifest): بخشِ مشترک (APP_MANIFEST با id: './'، آیکون، share_target) در vite.config.ts و نام، توضیح، lang و dir از کاتالوگِ همان زبان (vite/manifest.ts)؛ مانیفستِ خودِ افزونه خاموش است. پیوندِ پیش‌فرض را documentHead می‌نشاند و applyDocumentLocale با تعویضِ زبان نامِ پرونده را عوض می‌کند (src/lib/manifest.ts یک منبع برای هر دو). آیکون‌ها در public/icons/ با node scripts/pwa-icons.mjs (کرومِ نصب‌شده) از icon.svg.
  • آزمون: e2e/pwa.spec.ts (مانیفست با آیکون‌های PNG و maskable، سرویس‌ورکر فعال، بارگذاری آفلاین). Lighthouse ۱۲ دستهٔ PWA ندارد؛ همین آزمون جای آن است.
  • تقسیم کد: مسیرهای ویرایشگر، هزینه، کارگاه، «پروندهٔ فرستاده‌شده» و پنجرهٔ پیرو، تأییدِ بازگردانی، نمای سه‌بعدی، پنل رندر، عکس، دادهٔ خانه، پنجره‌ها و بستهٔ گزارش (@darzsaz/report با قلم‌های چاپ) با lazy/import() می‌آیند. scripts/size-budget.mjs علاوه بر بودجهٔ هر نوع، تکهٔ اولیه (ورودی + importهای ایستا + CSS) را می‌سنجد: سقف ۱۶۵ کیلوبایت gzip.