Add the cloud sync engine

Walks the local store and maps it onto the cloud object model per the
design doc: captures, generations (+ version wavs), profiles (+ samples,
avatar), and the settings singletons. Records carry the full row (paths
storage-relative so restores re-anchor under the destination data dir);
audio travels as per-asset ciphertext blobs.

Envelopes are randomized, so a new cloud_sync_state table remembers both
the plaintext fingerprint (did content actually change?) and the
ciphertext hash the server holds (re-declare unchanged blobs without
re-encrypting or re-uploading). updated_at is excluded from fingerprints
- its onupdate trigger fires as a side effect of applying pulls and
would bounce synced objects otherwise. Push runs before pull; conflicts
are last-writer-wins per object. Own pushes echoing back through the
feed are recognized by ciphertext hash and skipped.

Tests simulate two machines (own DB, data dir, keychain) syncing through
an in-process fake cloud: full backup/restore with byte-identical audio,
incremental edit (only the record blob re-uploads), delete propagation,
LWW convergence, and the blindness check that server storage only ever
holds VBX1 envelopes.
This commit is contained in:
Jamie Pine
2026-07-01 15:16:04 -07:00
parent 9b0e024d3b
commit 25602555b1
7 changed files with 1042 additions and 56 deletions
+2
View File
@@ -12,6 +12,7 @@ from .models import (
CaptureSettings,
ChannelDeviceMapping,
CloudSettings,
CloudSyncState,
EffectPreset,
Generation,
GenerationSettings,
@@ -34,6 +35,7 @@ __all__ = [
"CaptureSettings",
"ChannelDeviceMapping",
"CloudSettings",
"CloudSyncState",
"EffectPreset",
"Generation",
"GenerationSettings",
+5 -2
View File
@@ -285,13 +285,16 @@ def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None:
def _migrate_cloud_settings(engine, inspector, tables: set[str]) -> None:
"""Add ``sync_device_id`` the server-assigned id from registering this
install as an encryption-capable sync device."""
"""Add the cloud sync columns: ``sync_device_id`` (the server-assigned id
from registering this install as an encryption-capable sync device) and
``sync_cursor`` (highest applied seq from the sync feed)."""
if "cloud_settings" not in tables:
return
columns = _get_columns(inspector, "cloud_settings")
if "sync_device_id" not in columns:
_add_column(engine, "cloud_settings", "sync_device_id VARCHAR", "sync_device_id")
if "sync_cursor" not in columns:
_add_column(engine, "cloud_settings", "sync_cursor INTEGER NOT NULL DEFAULT 0", "sync_cursor")
def _supports_drop_column(engine) -> bool:
+33 -1
View File
@@ -3,7 +3,7 @@
from datetime import datetime
import uuid
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON, UniqueConstraint
from sqlalchemy.ext.declarative import declarative_base
from ..utils.capture_chords import (
@@ -257,9 +257,41 @@ class CloudSettings(Base):
# this install registers as an encryption-capable device; the matching
# X25519 private key lives in the OS keychain (services/cloud_keys.py).
sync_device_id = Column(String, nullable=True)
# Highest server seq this install has applied from the sync feed.
sync_cursor = Column(Integer, nullable=False, default=0)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class CloudSyncState(Base):
"""Per-entity sync bookkeeping for cloud backup (one row per synced object).
Envelopes are randomized (fresh content key + nonce per encryption), so the
ciphertext hash changes on every re-encrypt even when the content didn't.
To keep the server's changed-hash dedup working, this table remembers what
was last uploaded: the plaintext fingerprints (to detect real local edits)
and the ciphertext hashes the server currently holds (to re-declare
unchanged blobs without re-encrypting or re-uploading them).
"""
__tablename__ = "cloud_sync_state"
__table_args__ = (UniqueConstraint("kind", "client_id", name="uq_cloud_sync_state_kind_client"),)
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
kind = Column(String, nullable=False) # capture | generation | profile | settings
client_id = Column(String, nullable=False) # the local entity id
server_object_id = Column(String, nullable=True)
# Client-bumped LWW version (object.version on the server).
version = Column(Integer, nullable=False, default=1)
# SHA-256 of the canonical plaintext record JSON at last push/pull.
record_fingerprint = Column(String, nullable=True)
# SHA-256 + size of the record ciphertext the server currently holds.
record_hash = Column(String, nullable=True)
record_size = Column(Integer, nullable=False, default=0)
# Per-asset bookkeeping, JSON: {clientAssetId: {role, fingerprint, hash, size}}
assets_json = Column(Text, nullable=False, default="{}")
last_synced_at = Column(DateTime, nullable=True)
class MCPClientBinding(Base):
"""Per-MCP-client settings (voice profile, engine, personality default).