หนึ่งวินาทีเป็นหน่วยเล็ก ๆ จนกว่าคุณจะจดมันลงทุกวินาทีของวันทำงาน การสุ่มตัวอย่างที่ 1 Hz เป็นเวลาแปดชั่วโมงคือ 28,800 แถว หนึ่งปีเต็มของคนที่นั่งทำงานอยู่ที่โต๊ะก็เกินเจ็ดล้านแถว และทุกแถวก็เล็ก น่าเบื่อ และเหมือนแถวข้าง ๆ เป๊ะ คำถามที่น่าสนใจสำหรับสถาปนิกไม่ใช่ว่าจะเก็บเจ็ดล้านแถวอย่างไร — ดาตาเบสไหน ๆ ก็ทำได้ขณะหลับ — แต่คือจะเก็บมันบนเครื่อง เครื่องเดียว ที่เป็นของคน คนเดียว โดย ไม่มีเซิร์ฟเวอร์อยู่ในวงจร ได้อย่างไร ให้ไฟล์ยังเปิดได้ คิวรีได้ และน่าเชื่อถือในปี 2050

นั่นคือโจทย์การออกแบบเบื้องหลัง Timex แอปจับเวลาอัตโนมัติของเราบน Mac Timex เป็นผลิตภัณฑ์โอเพนซอร์สแบบปิด — แอปที่คุณซื้อที่ gettimex.app — แต่สถาปัตยกรรมที่อยู่ใต้มันคือสิ่งที่วิศวกรทุกคนที่สร้างเครื่องมือเดสก์ท็อปต้องคิดทบทวนในที่สุด และไม่มีอะไรเป็นความลับ ดังนั้นโพสต์นี้คือคู่มือการออกแบบ ไม่ใช่การทิ้งซอร์สโค้ด ผมจะพูดถึงแพตเทิร์น: local-first, ไฟล์ SQLite ฝังตัวไฟล์เดียว, การสุ่มตัวอย่างที่ 1 Hz, และการม้วนวินาทีดิบเป็นเซสชันที่มนุษย์จริง ๆ จำได้ สคีมาและคิวรีด้านล่างผมเขียนขึ้นเองเพื่อโพสต์นี้เพื่อแสดงรูปทรง ไม่ใช่การก๊อปปี้จากผลิตภัณฑ์

ชุดเครื่องมือนักพัฒนาของ Muvon — Octomind, Octocode, และที่เหลือ — เป็นโอเพนซอร์สภายใต้ Apache-2.0 Timex คือตัวที่เราเก็บเป็นแบบปิด สถาปัตยกรรมก็ยังควรค่าแก่การเล่าอย่างซื่อตรง


Local-first คือการตัดสินใจ ไม่ใช่อารมณ์

"Local-first" ถูกใช้เป็นคำการตลาด ซึ่งน่าเสียดาย เพราะจริง ๆ แล้วมันเป็นการตัดสินใจเชิงสถาปัตยกรรมที่รับน้ำหนัก พร้อมผลพวงที่คุณผูกมัดตั้งแต่วันแรก

การตัดสินใจคือ: แหล่งความจริงอยู่บนอุปกรณ์ของผู้ใช้ และแอปทำงานได้ครบถ้วนเมื่อถอดเครือข่ายออก ไม่ใช่ "ทำงานออฟไลน์แล้วซิงก์ทีหลัง" — นั่นคือ offline-first ซึ่งเป็นสถาปัตยกรรมซิงก์ที่มีแคชโลคัลขันสกรูแปะไว้ข้างบน Local-first หมายถึงไม่มี ทีหลัง เลย ไม่มีเซิร์ฟเวอร์ที่ถือสำเนาทางการ ไฟล์บนดิสก์ คือ ความทรงจำของผลิตภัณฑ์ และทุกอย่างที่เหลือเป็นเพียงมุมมองของมัน

สำหรับแอปจับเวลาเดสก์ท็อปผู้ใช้คนเดียว นี่ไม่ใช่การตัดสินใจที่ยาก ลองไล่ทางเลือก:

  • ดาตาเบสบนคลาวด์ ตอนนี้คุณต้องเดินเครื่องเซิร์ฟเวอร์ คุณมี round-trip เครือข่ายบนเส้นทางเขียนสำหรับข้อมูลที่ถูกสร้างวินาทีละครั้ง ตลอดกาล โดยผู้ใช้ทุกคน คุณมีงบสำหรับ outage, กลยุทธ์สำรองข้อมูล, พื้นผิว GDPR, สิ่งที่ถูกเจาะได้ และบิลที่เรียกซ้ำซึ่งคุณต้องผลักไปให้ผู้ใช้เป็นค่าสมาชิก สำหรับเครื่องมือที่งานทั้งหมดคือบันทึกว่า คนคนหนึ่ง ทำอะไรบน Mac เครื่องหนึ่ง เซิร์ฟเวอร์คือภาระล้วน ๆ
  • ล็อกไบนารีของตัวเอง ชวนหลงใหล — append อย่างเดียว เร็ว กระชับ แต่ตอนนี้คุณเป็นเจ้าของฟอร์แมตตลอดไป คุณเขียนตัวอ่าน เครื่องมือไมเกรชัน ลอจิกบีบอัด โค้ดกู้คืนจากแครช วันที่ผู้ใช้อยากจะ "แค่คิวรีข้อมูลของฉัน" คุณต้องส่งมอบเครื่องคิวรี คุณประดิษฐ์ดาตาเบสขึ้นใหม่ แบบห่วย ๆ และขังผู้ใช้ไว้ในฟอร์แมตที่มีแต่โค้ดของคุณเข้าใจ
  • ดาตาเบส SQL ฝังตัวในไฟล์เดียว หนึ่งไฟล์ ไม่มีเซิร์ฟเวอร์ ทรานแซกชัน ACID ภาษาคิวรีที่ทุกคนรู้จักอยู่แล้ว ฟอร์แมตไฟล์ที่เสถียรมากว่าสองทศวรรษ และเป็นฟอร์แมตจัดเก็บที่หอสมุดรัฐสภาสหรัฐแนะนำอย่างเป็นทางการสำหรับชุดข้อมูล อ่านได้ด้วยเครื่องมือนับร้อยที่คุณไม่ได้เขียน

ทางเลือกที่สามคือ SQLite และสำหรับแอปเดสก์ท็อปที่มีผู้เขียนคนเดียว มันไม่ใช่การประนีประนอม — มันคือคำตอบที่ถูกต้อง เรื่องความเป็นส่วนตัวที่ผู้คนชอบ ("ไม่มีคลาวด์ ไม่มีเทเลเมทรี ข้อมูลของคุณอยู่บน Mac ของคุณ") เป็นผลสืบเนื่องจากสิ่งนี้ มันไม่ใช่นโยบายที่เราเขียน มันคือสถาปัตยกรรม: ไม่มี endpoint ที่ข้อมูลกิจกรรมของคุณจะรั่วออกไปได้ เพราะมันไม่เคยออกจากไฟล์ (การเรียกเครือข่ายเดียวที่ Timex ทำคือการเปิดใช้งานไลเซนส์ — ประวัติการติดตามของคุณไม่มีส่วนในนั้น)


ทำไมต้อง SQLite โดยเฉพาะ

SQLite เป็นดาตาเบสที่ถูกติดตั้งมากที่สุดในโลก — มันอยู่ในโทรศัพท์ เบราว์เซอร์ รถยนต์ของคุณ — และเหตุผลที่มันเหมาะกับแอปเดสก์ท็อปควรค่าแก่การพูดออกมาดัง ๆ เพราะเป็นเหตุผลเดียวกับที่คนซึ่งคิดแต่ในกรอบไคลเอนต์-เซิร์ฟเวอร์มองข้ามมันไป

มันฝังตัว ไม่ใช่เซิร์ฟเวอร์ ไม่มีโพรเซสให้จัดการ ไม่มีพอร์ตให้ผูก ไม่มี connection pool ไม่มี pg_hba.conf ดาตาเบสคือไลบรารีที่ลิงก์เข้าไปในแอปของคุณ "การเชื่อมต่อ" คือการเรียกฟังก์ชัน สำหรับแอปผู้ใช้คนเดียว นี่ตัดความกังวลเชิงปฏิบัติการออกไปทั้งหมวด

มันคือไฟล์เดียว เรื่องสำรองข้อมูลของคุณคือ cp เรื่อง "เอ็กซ์พอร์ต" ของคุณคือ "นี่ไฟล์" เรื่องซิงก์ของคุณ ถ้าอยากได้สักวัน คือ "ย้ายไฟล์" — พร้อมข้อแม้ที่ผมจะพูดถึง ผู้ใช้หามันเจอ ก๊อปลง USB โยนลงโฟลเดอร์ที่ iCloud หรือ Dropbox เฝ้าดูอยู่ แล้วไม่มีอะไรในแอปของคุณพัง เทียบกับการอธิบายให้ผู้ใช้ที่ไม่ใช่สายเทคว่าจะเอ็กซ์พอร์ตข้อมูลจากบัญชีคลาวด์อย่างไรสิ

มันคิวรีได้โดยทุกคน นี่คือส่วนที่สำคัญที่สุดที่ทำให้ "เป็นเจ้าของข้อมูลของคุณ" เป็นความจริงไม่ใช่สโลแกน ข้อมูลของผู้ใช้อยู่ในฟอร์แมตที่ sqlite3 CLI, DB Browser for SQLite, มอดูล sqlite3 ของ Python, DuckDB, Datasette และเครื่องมือวิเคราะห์เกือบทุกตัวบนโลกอ่านได้โดยตรง คุณไม่ใช่ยามเฝ้าประตูข้อมูลของผู้ใช้ของคุณเอง พวกเขาตอบคำถามที่คุณไม่เคยสร้างหน้าจอให้ได้

มันทนทานและเป็นทรานแซกชัน SQLite เป็น ACID COMMIT หนึ่งครั้งจะเกิดขึ้นครบหรือไม่เกิดเลย แม้ไฟดับกลางการเขียน สำหรับเครื่องมือที่บันทึกสายธารต่อเนื่อง คำถามว่า "ดาตาเบสจะเป็นยังไงถ้าฝาพับปิดปึงในไมโครวินาทีที่ผิด" มีคำตอบน่าเบื่อแต่ถูกต้อง แทนที่จะเป็นไฟล์เสีย

ข้อโต้แย้งที่คุณจะได้ยินคือ "SQLite ไม่สเกล / ไม่รองรับผู้เขียนพร้อมกัน" จริงทั้งคู่และไม่เกี่ยวทั้งคู่ตรงนี้ แอปเดสก์ท็อปผู้ใช้คนเดียวมีผู้เขียนพอดีหนึ่งราย: ตัวมันเอง สิ่งที่ SQLite ทำได้แย่ — หลายเครื่องกระหน่ำเขียนผ่านเครือข่าย — เป็นสิ่งที่สถาปัตยกรรมนี้ไม่เคยทำ การเลือกดาตาเบสให้เข้ากับแพตเทิร์นการเข้าถึงคือเกมทั้งหมด และแพตเทิร์นการเข้าถึงตรงนี้คือ ผู้เขียนหนึ่งราย, ส่วนใหญ่เป็น append, อ่านเชิงวิเคราะห์เป็นครั้งคราว นั่นคือสนามบ้านของ SQLite


ลูปสุ่มตัวอย่างที่ 1 Hz

งานของตัวจับคือตอบ "ผู้ใช้กำลังทำอะไร" อย่างต่อเนื่อง วิธีคือตัวสุ่ม: วินาทีละครั้ง ถามระบบปฏิบัติการสามคำถาม

  1. แอปพลิเคชันไหนอยู่หน้าสุด?
  2. ชื่อหน้าต่างที่โฟกัสอยู่คืออะไร?
  3. (ถ้าได้รับอนุญาต สำหรับเบราว์เซอร์) URL ของแท็บที่ใช้งานคืออะไร?

บน macOS คำตอบเหล่านั้นมาจาก API ของแอปพลิเคชันหน้าสุดและทรี Accessibility (AXUIElement) — สิทธิ์เดียวกันที่เปิดเผยชื่อหน้าต่างก็เปิดเผยแถบที่อยู่ของเบราว์เซอร์ ซึ่งเป็นเหตุผลที่ Timex อ่าน URL ของแท็บได้โดยไม่ต้องมี prompt ของ Automation แยกสำหรับเบราว์เซอร์ส่วนใหญ่ ไม่มีอะไรเป็นกรรมสิทธิ์; มันคือพื้นผิว macOS ที่มีเอกสารกำกับซึ่งตัวจับใด ๆ ก็ใช้ สิ่งที่คุณทำกับตัวอย่างคือที่ที่การออกแบบอยู่

การ implement แบบไร้เดียงสาเขียนหนึ่งแถวต่อหนึ่งตัวอย่าง หนึ่งวินาที หนึ่งแถว มันถูกต้องและสิ้นเปลือง: 28,800 แถวที่เกือบเหมือนกันสำหรับวันแปดชั่วโมงที่คุณใช้สองชั่วโมงในหน้าต่างเอดิเตอร์เดิม การเก็บ "เอดิเตอร์, เอดิเตอร์, เอดิเตอร์, …" 7,200 ครั้งไม่ใช่ข้อมูล มันคือพิกเซลค้าง

โมเดลที่ดีกว่าปฏิบัติต่อสายธารวินาทีต่อวินาทีเป็น อินพุต และ เซกเมนต์ เป็น เอาต์พุต เซกเมนต์คือช่วงต่อเนื่องที่คำตอบของทั้งสามคำถามยังคงเดิม คุณเก็บเซกเมนต์ที่เปิดอยู่ปัจจุบันไว้ในหน่วยความจำ — (bundleId, title, url, startAt) — และในแต่ละ tick คุณเปรียบเทียบตัวอย่างใหม่กับมัน:

  • บริบทเดิม? ขยายเซกเมนต์ที่เปิดอยู่ ไม่มีอะไรลงดิสก์
  • บริบทเปลี่ยน? ปิดเซกเมนต์ที่เปิดอยู่ (ประทับ endAt คำนวณ duration) เขียนมันลง แล้วเปิดอันใหม่

ดังนั้นเซสชันเอดิเตอร์สองชั่วโมงคือ หนึ่ง แถวที่มีระยะเวลาสองชั่วโมง ไม่ใช่ 7,200 แถว ดิสก์เห็นการเขียนเฉพาะเมื่อมีอะไรเปลี่ยนจริง ๆ — ซึ่งสำหรับงานมนุษย์จริงเกิดน้อยกว่าวินาทีละครั้งมาก ลูป 1 Hz ให้ความละเอียดแก่คุณ; โมเดลเซกเมนต์ให้การจัดเก็บที่มีเหตุผล คุณได้ทั้งสองอย่าง

tick (1 Hz) ─▶ ตัวอย่าง(app, title, url)
                   │
                   ├─ เท่ากับเซกเมนต์ที่เปิดอยู่? ──▶ ขยายในหน่วยความจำ, ไม่เขียน
                   │
                   └─ ต่างกัน? ──▶ ปิด + flush เซกเมนต์ที่เปิดอยู่ ──▶ เปิดอันใหม่

ความว่างเปล่าไม่ใช่บริบท

ตัวสุ่มที่วิ่งแบบหลับหูหลับตาจะบันทึก "เอดิเตอร์" แปดชั่วโมงอย่างมีความสุข เพราะเอดิเตอร์อยู่หน้าสุดตอนคุณไปกินข้าวเที่ยง ข้อมูลนั้นเป็นเรื่องโกหก และแอปจับเวลาที่โกหกแย่กว่าไม่มีแอปจับเวลาเลย

ดังนั้นคุณต้องมี เกตความว่าง (idle gate): ติดตามกิจกรรมอินพุต และหลังเกณฑ์หนึ่งที่ไม่มีคีย์บอร์ดหรือเมาส์ (Timex ใช้สองนาที) ให้หยุดผูกเวลาให้แอปเบื้องหน้า วิธีที่ซื่อตรงในการสร้างโมเดลคือปฏิบัติต่อความว่างเป็นการเปลี่ยนสถานะที่ปิดเซกเมนต์ที่ใช้งานอยู่ เหมือนการสลับบริบทเป๊ะ เมื่ออินพุตกลับมา เซกเมนต์ใหม่เปิดขึ้น ช่องว่างระหว่างทั้งสองเป็นของจริง — มันคือเวลาที่เครื่องเปิดอยู่แต่มนุษย์ไม่อยู่ — และคุณจะเก็บช่องว่างนั้นเป็นข้อเท็จจริงชั้นหนึ่งในสคีมา หรืออนุมานจากรอยต่อระหว่างเซกเมนต์ก็ได้ — Timex ใช้วิธีหลัง

มีการตัดสินใจฝังอยู่ตรงนี้: วินาที ก่อน ที่คุณจะว่างยังถูกผูกให้สิ่งที่อยู่หน้าสุด ไม่มีคำตอบสมบูรณ์แบบ (คุณไม่อาจรู้ว่าผู้ใช้ลุกไปจนกว่าเขาจะไม่กลับมา) ดังนั้นการออกแบบเลือกเกณฑ์หนึ่งและบันทึกมันไว้ ความซื่อตรงเรื่องขอบเบลอดีกว่าความแม่นยำจอมปลอมที่แสร้งว่าการสุ่มตัวอย่างอ่านใจคนได้


สคีมาสำหรับช่วงเวลา

นี่คือสคีมาตัวอย่าง — ของผม เขียนขึ้นเพื่อโพสต์นี้ — ที่จับโมเดลเซกเมนต์ไว้ มันคือรูปทรง ไม่ใช่ DDL จริงของผลิตภัณฑ์ แต่เป็นสิ่งที่สมเหตุสมผลอย่างยิ่งที่จะสร้าง

-- เซกเมนต์กิจกรรมที่ปิดแล้ว: หน่วยที่ม้วนรวมแล้ว ไม่ใช่ตัวอย่างดิบ
CREATE TABLE activity (
  id         INTEGER PRIMARY KEY,
  bundle_id  TEXT    NOT NULL,        -- เช่น com.apple.dt.Xcode
  app_name   TEXT    NOT NULL,
  title      TEXT,                    -- ชื่อหน้าต่างที่โฟกัส (nullable)
  url        TEXT,                    -- แท็บที่ใช้งาน เฉพาะเบราว์เซอร์
  domain     TEXT,                    -- host ที่สกัดมา เพื่อจัดกลุ่มเร็ว
  started_at INTEGER NOT NULL,        -- วินาที unix epoch
  ended_at   INTEGER NOT NULL,
  duration   INTEGER NOT NULL,        -- ended_at - started_at, ดีนอร์มอลไลซ์
  category_id INTEGER REFERENCES category(id)
);

CREATE INDEX idx_activity_started ON activity(started_at);
CREATE INDEX idx_activity_bundle  ON activity(bundle_id);
CREATE INDEX idx_activity_domain  ON activity(domain) WHERE domain IS NOT NULL;

CREATE TABLE category (
  id    INTEGER PRIMARY KEY,
  name  TEXT NOT NULL,
  color TEXT NOT NULL,                -- hex
  score INTEGER NOT NULL             -- น้ำหนักผลิตภาพ, -100..100
);

-- จับคู่แอปกับหมวดหมู่เริ่มต้น ผู้ใช้แทนที่ได้
CREATE TABLE app_category (
  bundle_id   TEXT PRIMARY KEY,
  category_id INTEGER NOT NULL REFERENCES category(id)
);

สามทางเลือกการออกแบบในนั้นควรค่าแก่การปกป้อง:

เก็บช่วงที่ปิดแล้ว ไม่ใช่ tick ดิบ ตาราง activity คือเซกเมนต์ ตัวอย่างวินาทีต่อวินาทีไม่เคยถูกบันทึกถาวร — มันอยู่หนึ่ง tick ในหน่วยความจำแล้วพับเข้าเซกเมนต์ที่เปิดอยู่ นี่คือความต่างระหว่างดาตาเบสที่โตปีละ ~7 ล้านแถว กับดาตาเบสที่โตเท่าจำนวนครั้งที่คุณสลับหน้าต่างจริง ซึ่งสำหรับคนส่วนใหญ่น้อยกว่าหนึ่งถึงสองอันดับของขนาด

ดีนอร์มอลไลซ์ duration มันหาได้จาก ended_at - started_at และการเก็บมันไว้อยู่ดีคือการละเมิด "อย่าเก็บสิ่งที่คำนวณได้" อย่างจงใจ เหตุผล: ทุกคิวรีที่ UI รันรวมระยะเวลา และคอลัมน์จำนวนเต็มที่เก็บไว้พร้อมดัชนีดีกว่าการคำนวณการลบใหม่ข้ามหลายล้านแถวในทุกคำถาม "สัปดาห์ของฉันไปไหนหมด" การดีนอร์มอลไลซ์เป็นเครื่องมือ; กฎคือดีนอร์มอลไลซ์สิ่งที่คุณอ่านตลอด และคำนวณสิ่งที่คุณอ่านนาน ๆ ที

ช่วงครึ่งเปิด [started_at, ended_at) จุดเริ่มรวมเข้า จุดจบไม่รวม นี่คือธรรมเนียมน่าเบื่อแต่ถูกต้องสำหรับช่วงเวลา: เซกเมนต์ที่อยู่ติดกันสองอันแบ่งกันใช้ timestamp ขอบโดยไม่ทับซ้อนและไม่มีช่องว่าง และ "เซกเมนต์นี้ตกอยู่ในวันนี้ไหม" คือ started_at < day_end AND ended_at > day_start ที่สะอาดโดยไม่มี off-by-one ที่เที่ยงคืน ทำผิดตรงนี้แล้วทุกการรวมผลจะถูกสาปอย่างแยบยล

สิ่งที่ผม ไม่ แสดงให้คุณดูคือประวัติไมเกรชันจริงของผลิตภัณฑ์ ดัชนีที่แน่นอน หรือลอจิกการ seed หมวดหมู่ — นั่นเป็นกรรมสิทธิ์ และตามจริงมันก็เป็นส่วนที่น่าเบื่อด้วย แพตเทิร์นข้างบนคือความรู้ที่ถ่ายโอนได้


คิวรีที่คุณรันบนไฟล์ของตัวเองได้

นี่คือที่ที่ "เป็นเจ้าของข้อมูลของคุณ" เลิกเป็นสโลแกน เพราะที่เก็บคือไฟล์ SQLite ธรรมดา ผู้ใช้ — ไม่ใช่แค่แอป — ถามคำถามกับมันได้ นี่คือคิวรีต่อสคีมาตัวอย่างข้างบนที่ผู้ใช้ใด ๆ ปรับและรันด้วย sqlite3 CLI บนดาตาเบสของตัวเองได้

วันนี้ไปไหนหมด แยกตามแอป:

SELECT app_name, SUM(duration) / 3600.0 AS hours
FROM activity
WHERE started_at >= strftime('%s', 'now', 'start of day')
GROUP BY bundle_id
ORDER BY hours DESC;

เว็บไซต์ยอดนิยมสัปดาห์นี้:

SELECT domain, SUM(duration) / 60 AS minutes
FROM activity
WHERE domain IS NOT NULL
  AND started_at >= strftime('%s', 'now', '-7 days')
GROUP BY domain
ORDER BY minutes DESC
LIMIT 20;

เวลาที่จดจ่อเทียบกับวอกแวก โดยใช้น้ำหนักหมวดหมู่:

SELECT c.name,
       SUM(a.duration) / 3600.0 AS hours
FROM activity a
JOIN category c ON c.id = a.category_id
WHERE a.started_at >= strftime('%s', 'now', '-30 days')
GROUP BY c.id
ORDER BY hours DESC;

ไม่มีอันไหนต้องการคำขอฟีเจอร์ การเอ็กซ์พอร์ต CSV หรือ API ผู้ใช้ป้อนไฟล์เข้า DuckDB ถ้าอยากได้ window function เข้า Datasette ถ้าอยากได้ UI เว็บ เข้าโน้ตบุ๊ก pandas ถ้าอยากได้กราฟ ผลิตภัณฑ์ส่งมอบมุมมอง Today; ข้อมูล ส่งมอบความสามารถในการตอบทุกอย่างที่เหลือ นั่นคือเงินปันผลของการเลือกฟอร์แมตที่ทั้งโลกอ่านได้


ความทนทาน: WAL, แครช และการเติบโตของไฟล์

แอปที่เขียนต่อเนื่องบนแล็ปท็อปที่ถูกพับฝาปิดปึงต้องมีคำตอบสำหรับ "ไฟล์จะเป็นยังไงเมื่อโพรเซสตายกลางการเขียน" คำตอบของ SQLite ดี แต่คุณต้องเลือกเข้าเวอร์ชันที่ดีอย่างตั้งใจ

ใช้โหมด WAL rollback journal เริ่มต้นทำให้ผู้อ่านต้องเรียงคิวต่อผู้เขียนและทำงาน fsync มากกว่า Write-Ahead Logging (PRAGMA journal_mode=WAL) เขียนการเปลี่ยนแปลงลงไฟล์ข้างเคียง -wal และปล่อยให้การอ่านดำเนินไปพร้อมกับผู้เขียนรายเดียว — ซึ่งเป็นรูปทรงของตัวจับที่ append เซกเมนต์ขณะที่ UI อ่านมันเพื่อวาดไทม์ไลน์เป๊ะ นี่คือเหตุผลที่คุณจะเห็นสามไฟล์บนดิสก์: timex.sqlite, timex.sqlite-wal, และ timex.sqlite-shm นั่นไม่ใช่ความเสียหาย; นั่นคือ WAL กำลังทำงานของมัน -wal ทำ checkpoint กลับเข้าไฟล์หลักเป็นระยะ

ความปลอดภัยเมื่อแครชเป็นของจริง แต่ปรับ sync ด้วย synchronous=NORMAL ภายใต้ WAL ไฟดับอาจทำให้คุณเสียทรานแซกชันสุดท้ายที่ยังไม่ checkpoint แต่ทำให้ดาตาเบสเสียหายไม่ได้ — ข้อแลกเปลี่ยนที่แอปเดสก์ท็อปส่วนใหญ่ควรรับ เพราะการเสียไม่กี่วินาทีสุดท้ายของ "คุณอยู่ในเอดิเตอร์" คือ rounding error และข้อแลกเปลี่ยนความทนทาน/ทรูพุตคุ้มค่า synchronous=FULL ปลอดภัยกว่าและช้ากว่า; สำหรับข้อมูลกิจกรรมความละเอียดวินาที NORMAL คือค่าเริ่มต้นที่มีเหตุผล สิ่งที่คุณต้องไม่ทำคือรันโดยปิดการซิงโครไนซ์เพื่อไล่ตามตัวเลขเบนช์มาร์ก แล้วทำหน้าตกใจตอน kernel panic กินไฟล์ไป

รวมการเขียนเป็นชุด แม้เซกเมนต์จะเกิดไม่บ่อยเมื่อเทียบกับตัวอย่าง การห่อการ flush ในทรานแซกชันและไม่เรียก fsync ทุกการเขียนจิ๋ว ๆ ทำให้ดิสก์มีความสุข ตัวสุ่มถือการเขียนไว้สั้น ๆ แล้ว flush ชุดเล็ก ๆ ได้; UI ไม่จำเป็นต้องให้เซกเมนต์อยู่บนดิสก์ในไมโครวินาทีที่มันปิด

วางแผนการเติบโตและการเก็บรักษา เซกเมนต์โตช้า แต่มันโตตลอดไป และ "ตลอดไป" เป็นเวลานานบนแล็ปท็อป 256 GB การออกแบบที่ซื่อตรงให้ผู้ใช้มีปุ่มหมุนการเก็บรักษา — เก็บเซกเมนต์ดิบ N เดือน เลือกได้ที่จะม้วนข้อมูลเก่าเป็นสรุปรายวันที่หยาบกว่า และ VACUUM เป็นครั้งคราวเพื่อทวงพื้นที่คืนจากการลบ คุณไม่ต้องสร้างทั้งหมดในวันแรก แต่คุณควรรู้ว่ามันไปทางไหน เพราะ "ดาตาเบสที่มีแต่โต" คือบั๊กแบบสโลว์โมชัน


ข้อแลกเปลี่ยนตรงไปตรงมาของ local-first

ผมคงขายนิทานเรื่องเดียวกับที่ผมวิจารณ์ ถ้าผมหยุดที่ด้านดี Local-first ซื้อความเป็นเจ้าของและความเป็นส่วนตัวให้คุณด้วยการจ่ายสองอย่าง และคุณควรรู้ว่ามันมีราคาเท่าไหร่

ไม่มีซิงก์ในตัว ถ้าคนหนึ่งใช้เดสก์ท็อปและแล็ปท็อป แต่ละเครื่องมีไฟล์ของตัวเองและความจริงของตัวเอง ไม่มีเซิร์ฟเวอร์มาปรองดองพวกมัน เพราะการไม่มีเซิร์ฟเวอร์นั้นคือประเด็นทั้งหมด คำตอบแบบเอาของตัวเองมาคือวางไฟล์ในโฟลเดอร์ที่ iCloud หรือ Dropbox ซิงก์ — แต่เข้าใจว่านี่คือการซิงก์ ไฟล์ ไม่ใช่ซิงก์ ดาตาเบส และ SQLite เตือนชัดเจนว่าดาตาเบส WAL ไม่ถูกกับเครื่องมือซิงก์ไฟล์แบบไร้เดียงสาที่ก๊อป .sqlite ขณะที่ -wal กำลัง flush มันใช้ได้ถ้าแอปปิดอยู่ตอนไฟล์ถูกย้าย; มันเสียหายได้ถ้าสองเครื่องเขียนโฟลเดอร์เดียวกันพร้อมกัน ดังนั้นจุดยืนที่ซื่อตรงคือ: local-first ให้สำเนาที่สมบูรณ์แบบหนึ่งชุดต่อเครื่อง และมัลติดีไวซ์เป็นฟีเจอร์แยกแบบ opt-in ที่คุณสร้างอย่างตั้งใจหรือไม่สร้างเลย

การสำรองข้อมูลเป็นงานของผู้ใช้ — ดังนั้นทำให้มันง่ายจิ๊บจ๊อย เมื่อไม่มีคลาวด์ ไม่มีใครสำรองประวัติของผู้ใช้นอกจากผู้ใช้เอง การบรรเทาคือการสำรองข้อมูลคือ ไฟล์เดียวที่คุณก๊อปได้ ซึ่งเป็นเรื่องสำรองข้อมูลที่ง่ายที่สุดในวงการซอฟต์แวร์ แต่ผลิตภัณฑ์ต้องบอกอย่างตรงไปตรงมาแทนที่จะแสร้งว่าคลาวด์คอยรับหลังให้ เพราะมันไม่ได้คอย โดยการออกแบบ

และนี่คือส่วนที่ควรพูดตรง ๆ: วินาทีที่คุณอยากได้ซิงก์มัลติดีไวซ์จริง ๆ คุณได้ลงชื่อรับหนึ่งในปัญหาที่ยากจริงของวิทยาการคอมพิวเตอร์ การซิงก์ดาตาเบสข้ามอุปกรณ์ที่แก้ไขอย่างอิสระและหลุดออฟไลน์คือแดนของการแก้ความขัดแย้ง, CRDT, นาฬิกาเวกเตอร์ และความเสียใจแบบ last-writer-wins มันไม่ใช่ "แค่ rsync ไฟล์" ผลิตภัณฑ์จำนวนมากที่เริ่มแบบ local-first แล้วมาขันสกรูซิงก์ทีหลังลงเอยด้วยการสร้างดาตาเบสคลาวด์ที่เคยภูมิใจว่าหลีกเลี่ยงได้ขึ้นใหม่ — ตอนนี้มีพื้นผิวบั๊กระบบกระจายอยู่ข้างบนด้วย ท่าที่ป้องกันได้คือส่งมอบ local-first เป็นค่าเริ่มต้นที่ซื่อตรง เปิดประตูไว้สำหรับซิงก์ opt-in ในฐานะฟีเจอร์ที่ตั้งใจและออกแบบแยกต่างหาก และอย่าปล่อยให้ความสะดวกของซิงก์ลากแหล่งความจริงกลับขึ้นเซิร์ฟเวอร์อย่างเงียบ ๆ วันที่สำเนาทางการอยู่ที่อื่นนอกจากดิสก์ของผู้ใช้ คุณไม่ใช่ local-first อีกต่อไป — คุณคือแอปคลาวด์ที่มีแคชออฟไลน์สวย ๆ ซึ่งเป็นสิ่งที่ดีที่จะเป็น เพียงแต่ไม่ใช่สิ่งที่คุณบอกว่าคุณเป็น


สิ่งนี้ให้อะไรแก่ผู้ใช้

ถอดทุกอย่างออก สถาปัตยกรรมให้คำสัญญาเพียงหนึ่งเดียว: ไฟล์เป็นของคุณ มันรอดจากบริษัทที่ทำแอปถูกเทกโอเวอร์ มันรอดจาก wifi ดับบนเครื่องบิน มันรอดจากพาดหัวข่าวการเจาะข้อมูลครั้งหน้า เพราะประวัติการติดตามของคุณไม่ได้อยู่บนเซิร์ฟเวอร์ของใครให้ถูกเจาะ มันอ่านได้ด้วยเครื่องมือที่จะยังไม่มีอีกทศวรรษ เพราะฟอร์แมตเก่ากว่าเครื่องมือส่วนใหญ่ที่มีอยู่ตอนนี้ ไม่มีบัญชีที่จะล็อกคุณออก ไม่มีค่าสมาชิกที่การหมดอายุลบประวัติของคุณ ไม่มีตัวช่วยเอ็กซ์พอร์ตให้ต้องอ้อนวอนขอข้อมูลของตัวเอง

นั่นคือคุณค่าที่นำเสนอทั้งหมดของ local-first และเป็นเหตุผลที่เราสร้าง Timex บนไฟล์ SQLite ไฟล์เดียวแทนที่จะเป็นแบ็กเอนด์ที่เราต้องโฮสต์ รักษาความปลอดภัย และปิดมันในที่สุด สัญชาตญาณเดียวกันไหลผ่านชุดโอเพนซอร์สของ Muvon — โค้ดของคุณ เครื่องมือของคุณ repo ของคุณ — และผ่านผลิตภัณฑ์ปิดเพียงตัวเดียวที่เราส่งมอบ: เวลาของคุณ ไฟล์ของคุณ เครื่องของคุณ

หนึ่งวินาทีเป็นหน่วยเล็ก ๆ เจ็ดล้านวินาทีในไฟล์ที่คุณเป็นเจ้าของและคิวรีได้ด้วยเครื่องมือใด ๆ บนโลก กลายเป็นความต่างระหว่าง "ปีของฉันไปไหนหมด" ที่เป็นคำถามเชิงวาทศิลป์ กับคำถามที่ตอบได้

— Vladimir


คำถามที่พบบ่อย

ทำไมต้อง SQLite แทนดาตาเบสบนคลาวด์สำหรับแอปเดสก์ท็อป?

เพราะแพตเทิร์นการเข้าถึงคือผู้เขียนหนึ่งรายบนเครื่องเดียว ส่วนใหญ่เป็น append พร้อมการอ่านเชิงวิเคราะห์เป็นครั้งคราว — ซึ่งเป็นที่ที่ดาตาเบสไฟล์เดียวฝังตัวเปล่งประกายพอดี และไคลเอนต์-เซิร์ฟเวอร์เป็นภาระเชิงปฏิบัติการล้วน ๆ ไม่มีเซิร์ฟเวอร์สำหรับข้อมูลของคุณหมายถึงไม่มีงบ outage ไม่มีบิลซ้ำ ไม่มีพื้นผิวการเจาะที่ถือประวัติของคุณ และเรื่องสำรองข้อมูลคือการก๊อปไฟล์

ทำไมต้องม้วนตัวอย่าง 1 Hz เป็นเซกเมนต์แทนการเก็บทุกวินาที?

ความละเอียดวินาทีต่อวินาทีคือสิ่งที่คุณต้องการเพื่อความแม่นยำ; การจัดเก็บ วินาทีต่อวินาทีคือความสิ้นเปลือง เพราะงานจริงใช้เวลาเป็นนาทีหรือชั่วโมงในบริบทเดิม การพับตัวอย่างที่เหมือนกันต่อเนื่องเป็นช่วงปิดหนึ่งช่วงลดจำนวนแถวลงหนึ่งถึงสองอันดับของขนาดโดยไม่สูญเสียความเที่ยง

ผมคิวรีข้อมูลของตัวเองได้จริงไหม?

ได้ — นั่นคือประเด็นของการเลือก SQLite ไฟล์เปิดได้ใน sqlite3 CLI, DB Browser, DuckDB, Datasette, โน้ตบุ๊ก Python หรืออะไรก็ตามที่อ่าน SQLite คิวรีตัวอย่างในโพสต์นี้รันบนสคีมาที่แสดง; ปรับให้เข้ากับไฟล์ของคุณ

Local-first เหมือนกับ offline-first ไหม?

ไม่ Offline-first คือแอปคลาวด์ที่มีแคชโลคัลซึ่งซิงก์เมื่อทำได้ — เซิร์ฟเวอร์ยังถือสำเนาทางการ Local-first หมายถึงอุปกรณ์ถือแหล่งความจริงและไม่มีสำเนาทางการบนเซิร์ฟเวอร์เลย คนละสถาปัตยกรรม คนละการรับประกัน

กับดักของ local-first คืออะไร?

ไม่มีซิงก์มัลติดีไวซ์ในตัว และการสำรองข้อมูลขึ้นกับคุณ ทั้งคู่เป็นต้นทุนจริง การบรรเทาคือ "ข้อมูลของคุณ" เป็นไฟล์เดียวที่ก๊อปไปไหนก็ได้ — แต่ถ้าคุณต้องการให้อุปกรณ์ผสานการแก้ไขอัตโนมัติ นั่นเป็นปัญหายากต่างหาก (การแก้ความขัดแย้ง, CRDT) ที่การก๊อปไฟล์เท่าไรก็ไม่แก้

Timex เป็นโอเพนซอร์สไหม?

ไม่ Timex เป็นผลิตภัณฑ์โอเพนซอร์สแบบปิด เครื่องมือนักพัฒนาของ Muvon — Octomind, Octocode และที่เหลือ — เป็นโอเพนซอร์สภายใต้ Apache-2.0 โพสต์นี้เกี่ยวกับสถาปัตยกรรมและแพตเทิร์น ซึ่งเป็นความรู้วิศวกรรมทั่วไป ไม่ใช่ซอร์สของผลิตภัณฑ์


Timex คือแอปจับเวลาอัตโนมัติบน Mac แบบปิดซอร์สและ local-first: การสุ่มตัวอย่างที่ 1 Hz ลงไฟล์ SQLite โลคัลไฟล์เดียวที่คุณเป็นเจ้าของ — ไม่มีการเก็บกิจกรรมของคุณบนคลาวด์ ไม่มีเทเลเมทรีว่าคุณทำอะไร การเรียกเครือข่ายเดียวคือการเปิดใช้งานไลเซนส์ สคีมาและคิวรีในที่นี้เป็นตัวอย่างและเขียนขึ้นเพื่อโพสต์นี้ ไม่ใช่ซอร์สของผลิตภัณฑ์ ชุดเครื่องมือนักพัฒนาของ Muvon — Octomind และผองเพื่อน — เป็นโอเพนซอร์สภายใต้ Apache-2.0