feat: v6.0.0 PWA offline support

- manifest + icones, service worker (precache, network-first, Background Sync)

- module client FlowOffline (IndexedDB, queue, delta, flush) + hook editeur

- endpoints /api/v2/sync/{delta,batch,status} + moteur de sync (conflits LWW/orpheline/copie offline)

- migrations offline_sync_queue + sync_version (triggers)

- UI offline (banner, badge sync, toasts, icone dirty) + doc /help

- tests pytest (sync, migrations, SW, offline) + E2E Playwright; bump 6.0.0
This commit is contained in:
2026-09-18 13:05:40 -04:00
parent 62620ef884
commit b5207216f1
35 changed files with 3115 additions and 23 deletions
+79
View File
@@ -630,3 +630,82 @@ def _migration_synced_blocks(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_psb_synced ON page_synced_blocks(synced_block_id)"
)
@register(16, "v6.0.0: offline sync queue")
def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
"""v6.0.0 — PWA offline support.
``offline_sync_queue`` persists server-side the mutations received from
offline clients (``/api/v2/sync/batch``) so work is not lost and can be
audited/replayed per device.
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS offline_sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
type TEXT NOT NULL,
-- 'page_create', 'page_update', 'page_delete', 'page_move',
-- 'collection_create', 'collection_update', 'collection_delete'
payload TEXT NOT NULL,
client_timestamp REAL NOT NULL,
server_version INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'syncing', 'synced', 'failed'
retries INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_syncqueue_user ON offline_sync_queue(user_id, status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_syncqueue_device ON offline_sync_queue(device_id, status)"
)
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
@register(17, "v6.0.0: sync_version columns")
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
Every write on a page / collection increments its ``sync_version`` so a
reconnecting client can detect edit-edit conflicts via version mismatch.
A ``BEFORE UPDATE`` trigger performs the increment automatically on every
write path (no need to patch dozens of ``UPDATE`` call-sites).
"""
for table in ("pages", "collection_pages", "collections"):
_add_sync_version(conn, table)
# AFTER UPDATE + inner UPDATE: bumps sync_version on every write path.
# `recursive_triggers` is OFF by default, so the inner UPDATE never
# re-fires the trigger (no infinite loop), incl. the page FTS triggers.
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS pages_sync_version_bu
AFTER UPDATE ON pages
FOR EACH ROW BEGIN
UPDATE pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS collection_pages_sync_version_bu
AFTER UPDATE ON collection_pages
FOR EACH ROW BEGIN
UPDATE collection_pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS collections_sync_version_bu
AFTER UPDATE ON collections
FOR EACH ROW BEGIN
UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)