Offline-first là cam kết sản phẩm

Một reading journal được mở trong những khoảng thời gian yên tĩnh: trên tàu, trong quán cà phê, trước khi ngủ hoặc ở nơi mạng không ổn định. Nếu thao tác ghi lại một trích dẫn phải chờ server, ứng dụng đã đặt hạ tầng trước thói quen đọc. Offline-first đảo thứ tự đó: mọi thao tác cốt lõi hoàn tất trên thiết bị trước, còn đồng bộ là một khả năng bổ sung, không phải điều kiện để sản phẩm hoạt động.

Điều này không đồng nghĩa “thêm cache”. Nguồn sự thật cho UI là cơ sở dữ liệu local; repository phát dữ liệu từ local và ghi thay đổi vào đó trong cùng tương tác. Network chạy phía sau để đẩy mutation và nhận bản cập nhật. Người dùng phải thấy ghi chú vừa lưu ngay lập tức, kể cả khi airplane mode đang bật.

Model theo hành vi đọc

Book, ReadingSession, Note và Quote nên là các thực thể riêng. Một cuốn sách có metadata tương đối ổn định; phiên đọc có thời điểm bắt đầu, kết thúc và tiến độ; note là suy nghĩ của người đọc; quote là đoạn trích gắn với vị trí. Nhét tất cả vào một document lớn khiến mỗi thay đổi nhỏ trở thành một lần ghi đè khó hợp nhất.

Book { id, title, authors, progress, updatedAt }
ReadingSession { id, bookId, startedAt, endedAt, pagesRead }
Annotation { id, bookId, kind, body, locator, updatedAt, deletedAt }

ID nên được tạo ở client để bản ghi tồn tại đầy đủ trước khi có mạng. Thời gian cập nhật phục vụ sắp xếp nhưng không nên là cơ chế duy nhất giải quyết conflict. Trạng thái xóa cần tombstone thay vì xóa vật lý ngay, nếu không một thiết bị offline có thể đưa bản ghi đã xóa trở lại.

Mutation queue có thể quan sát

Mỗi thay đổi local tạo một operation trong outbox: entity, loại thao tác, payload, phiên bản cơ sở và số lần thử. Worker đồng bộ đọc queue khi có kết nối. Nếu gửi thành công, operation được đánh dấu; nếu thất bại tạm thời, retry với backoff; nếu conflict, chuyển sang quy tắc hợp nhất. Queue cần hiển thị được trong diagnostics để lỗi “không sync” có bằng chứng thay vì phỏng đoán.

  • Ghi dữ liệu và outbox trong cùng transaction.
  • Operation có idempotency key để retry không tạo bản sao.
  • Backoff có jitter, tránh mọi thiết bị thử lại cùng lúc.
  • Lỗi vĩnh viễn được giữ lại đủ thông tin để hỗ trợ hoặc phục hồi.

Conflict không có một đáp án chung

Progress có thể dùng giá trị mới nhất hoặc lớn nhất tùy cách người dùng ghi lùi. Session là dữ liệu append-only nên thường hợp nhất bằng union theo ID. Hai note khác nhau cùng được thêm thì giữ cả hai. Cùng một note được sửa trên hai thiết bị cần lựa chọn: last-write-wins đơn giản nhưng có thể mất chữ; duplicate-as-copy giữ dữ liệu nhưng tạo việc dọn dẹp. Quy tắc phải theo giá trị của từng loại dữ liệu.

Một chiến lược thực dụng là ưu tiên không mất dữ liệu. Với nội dung người dùng tự viết, khi không thể merge an toàn, giữ bản local, tạo một bản conflict và cho phép so sánh sau. Với metadata có thể tái tạo, last-write-wins hợp lý hơn. Không nên quảng bá “seamless sync” nếu chưa thiết kế từng conflict path.

File cá nhân cần ranh giới rõ

EPUB, MOBI và PDF lớn hơn metadata rất nhiều. Lưu file trong database làm backup và migration nặng nề. Tốt hơn là database giữ file handle, checksum, metadata đọc và vị trí hiện tại; binary nằm trong storage chuyên dụng. Nếu người dùng chưa bật đồng bộ file, đường dẫn chỉ hợp lệ trên thiết bị và UI cần nói rõ.

Import phải chịu được trùng lặp, file bị di chuyển và quyền truy cập bị thu hồi. Checksum giúp nhận ra cùng nội dung, nhưng hai edition có thể khác. Parser chạy ngoài luồng UI, lưu tiến độ, và thất bại không được làm mất bản ghi journal đã có.

Tối thiểu hóa dữ liệu ngay từ kiến trúc

Nhật ký đọc có thể tiết lộ sở thích, niềm tin và thói quen. Offline-first tự nhiên giảm lượng dữ liệu buộc phải rời thiết bị, nhưng chỉ khi telemetry và backup cũng được xem xét. Thu thập ít nhất cần thiết, tách crash data khỏi nội dung note, và không đưa quote vào log. Nếu có tài khoản sync, người dùng cần biết dữ liệu nào được tải lên và cách xóa nó.

Encryption at rest phụ thuộc khả năng nền tảng, nhưng secret và token luôn cần kho bảo mật. Một chính sách retention rõ ràng quan trọng không kém thuật toán: tombstone cần giữ đủ lâu để đồng bộ xóa, sau đó phải được dọn. Export nên tạo định dạng con người đọc được để người dùng không bị khóa trong ứng dụng.

Kiểm thử bằng trạng thái mạng xấu

Happy path online chỉ kiểm tra phần dễ nhất. Test nên chạy chuỗi thao tác khi offline, đóng ứng dụng, khởi động lại, bật mạng và xác nhận dữ liệu hội tụ. Mô phỏng timeout sau khi server đã nhận mutation để kiểm tra idempotency. Tạo hai thiết bị sửa cùng note, xóa trên một thiết bị và chỉnh trên thiết bị kia, rồi xác nhận quy tắc conflict không làm mất dữ liệu âm thầm.

offline -> create note -> kill app -> relaunch
online -> retry same operation twice
assert one server note and one local note
assert outbox becomes empty

Sự yên tâm là một tính năng

Book Memory cần biến việc lưu một ý nghĩ thành phản xạ không bị gián đoạn. Local database làm nguồn thật, outbox có transaction, sync idempotent, conflict theo từng loại dữ liệu và ranh giới rõ cho file cá nhân tạo nền cho cảm giác đó. Người dùng không cần biết hàng đợi hoạt động ra sao. Họ chỉ cần biết rằng câu vừa ghi sẽ vẫn ở đó vào ngày mai, dù lúc ghi không có mạng.