mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 07:10:40 -07:00
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:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user