Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,16 @@ item versions, clean base values, dirty state, and conflict metadata. Keeping
that metadata lets a later re-enable reconcile local and remote changes without
turning the next sync into a first-sync collision.

When an app detects `bitboxsync.ErrRollback`, it can call
`Store.ResetSyncState(ctx, keyID)` to clear namespace and item metadata for that
identity while keeping the bearer token and expiry. This is a store-level repair
step: stop the current engine run, stop using the current `Engine`, `Namespace`,
and `Collection` handles, reset the store state, then construct a fresh engine
and recreate the handles. The fresh engine can recreate the default namespace
cache and re-bootstrap item state from the server. If closing the old engine also
closes the store handle, reopen the store before resetting or reset through a
separate store handle.

The bundled SQLite backend is suitable for sync metadata. It writes each metadata row with a single `INSERT ... ON CONFLICT DO UPDATE` statement, so each `SaveItem` is atomic for one item, but it does not group multiple items into one transaction.

The engine reads and writes collection values one key at a time after snapshot
Expand Down
25 changes: 25 additions & 0 deletions bitboxsync/collection_external_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,31 @@ func (s *testStore) ForgetIdentitySecrets(_ context.Context, keyID string) error
return nil
}

func (s *testStore) ResetSyncState(_ context.Context, keyID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if state, ok := s.identities[keyID]; ok {
state.DefaultNamespaceID = ""
s.identities[keyID] = state
}
for namespaceKey, state := range s.namespaces {
if state.KeyID == keyID {
delete(s.namespaces, namespaceKey)
}
}
for itemKey, state := range s.itemsByID {
if state.KeyID == keyID {
delete(s.itemsByID, itemKey)
}
}
for logicalKey, itemKey := range s.itemsByKey {
if _, ok := s.itemsByID[itemKey]; !ok {
delete(s.itemsByKey, logicalKey)
}
}
return nil
}

func (s *testStore) GetItemByID(_ context.Context, keyID, namespaceID, itemID string) (ItemState, error) {
s.mu.RLock()
defer s.mu.RUnlock()
Expand Down
4 changes: 4 additions & 0 deletions bitboxsync/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ type Store interface {
// as bearer tokens and unwrapped namespace DEKs, while preserving namespace
// and item metadata used for later merge reconciliation.
ForgetIdentitySecrets(ctx context.Context, keyID string) error
// ResetSyncState clears locally cached namespace and item metadata for one
// identity while preserving the auth/session state. This is useful after a
// server-side rollback invalidates the local version history.
ResetSyncState(ctx context.Context, keyID string) error
// GetItemByID loads an item by its opaque item ID. It must return ErrNotFound
// when the item is unknown locally.
GetItemByID(ctx context.Context, keyID, namespaceID, itemID string) (ItemState, error)
Expand Down
42 changes: 42 additions & 0 deletions storage/sqlite/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,48 @@ func (s *Store) ForgetIdentitySecrets(ctx context.Context, keyID string) error {
return tx.Commit()
}

// ResetSyncState clears cached sync metadata while preserving auth/session
// state so the next sync can bootstrap from the server again.
func (s *Store) ResetSyncState(ctx context.Context, keyID string) error {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()

if _, err := tx.ExecContext(
ctx,
`DELETE FROM items
WHERE key_id = ?`,
keyID,
); err != nil {
return err
}
if _, err := tx.ExecContext(
ctx,
`DELETE FROM namespaces
WHERE key_id = ?`,
keyID,
); err != nil {
return err
}
if _, err := tx.ExecContext(
ctx,
`UPDATE identity_states
SET default_namespace_id = NULL,
updated_at = ?
WHERE key_id = ?`,
now,
keyID,
); err != nil {
return err
}
return tx.Commit()
}

// GetItemByID loads one cached item by its opaque item ID.
func (s *Store) GetItemByID(ctx context.Context, keyID, namespaceID, itemID string) (bitboxsync.ItemState, error) {
return s.getItem(ctx, `SELECT key_id, namespace_id, collection_name, item_key, item_id, version, base_version, base_value, dirty, conflict, conflict_remote_version, conflict_remote_value, updated_at
Expand Down
84 changes: 84 additions & 0 deletions storage/sqlite/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,90 @@ func TestForgetIdentitySecretsKeepsSyncMetadata(t *testing.T) {
require.Equal(t, []byte("other-namespace-dek"), otherNamespace.DEK)
}

func TestResetSyncStateKeepsAuthState(t *testing.T) {
ctx := context.Background()

store, err := OpenInMemory()
require.NoError(t, err)
defer closeStore(t, store)

keyID := "identity-a"
otherKeyID := "identity-b"
namespaceID := "namespace-a"
tokenExpiry := time.Now().Add(time.Hour).UTC()
require.NoError(t, store.SaveIdentity(ctx, bitboxsync.IdentityState{
KeyID: keyID,
Kind: "keystore",
AccessToken: "access-token",
TokenExpiry: tokenExpiry,
DefaultNamespaceID: namespaceID,
}))
require.NoError(t, store.SaveIdentity(ctx, bitboxsync.IdentityState{
KeyID: otherKeyID,
Kind: "keystore",
AccessToken: "other-access-token",
TokenExpiry: time.Now().Add(time.Hour).UTC(),
DefaultNamespaceID: "namespace-b",
}))
require.NoError(t, store.SaveNamespace(ctx, bitboxsync.NamespaceState{
KeyID: keyID,
NamespaceID: namespaceID,
Kind: "default",
NamespaceHead: 42,
ActiveScopeHash: "scope",
DEK: []byte("namespace-dek"),
}))
require.NoError(t, store.SaveNamespace(ctx, bitboxsync.NamespaceState{
KeyID: otherKeyID,
NamespaceID: "namespace-b",
Kind: "default",
NamespaceHead: 99,
ActiveScopeHash: "other-scope",
DEK: []byte("other-namespace-dek"),
}))
require.NoError(t, store.SaveItem(ctx, bitboxsync.ItemState{
KeyID: keyID,
NamespaceID: namespaceID,
Collection: "collection",
Key: "key",
ItemID: "item-id",
Version: 7,
BaseVersion: 7,
BaseValue: []byte("base-value"),
}))

require.NoError(t, store.ResetSyncState(ctx, keyID))

identity, err := store.LoadIdentity(ctx, keyID)
require.NoError(t, err)
require.Equal(t, "access-token", identity.AccessToken)
require.WithinDuration(t, tokenExpiry, identity.TokenExpiry, time.Second)
require.Empty(t, identity.DefaultNamespaceID)

_, err = store.GetNamespace(ctx, keyID, namespaceID)
require.ErrorIs(t, err, bitboxsync.ErrNotFound)
_, err = store.GetItemByLogicalKey(ctx, keyID, namespaceID, "collection", "key")
require.ErrorIs(t, err, bitboxsync.ErrNotFound)

otherIdentity, err := store.LoadIdentity(ctx, otherKeyID)
require.NoError(t, err)
require.Equal(t, "other-access-token", otherIdentity.AccessToken)
require.Equal(t, "namespace-b", otherIdentity.DefaultNamespaceID)
otherNamespace, err := store.GetNamespace(ctx, otherKeyID, "namespace-b")
require.NoError(t, err)
require.Equal(t, []byte("other-namespace-dek"), otherNamespace.DEK)
}

func TestResetSyncStateMissingIdentityIsNoOp(t *testing.T) {
ctx := context.Background()

store, err := OpenInMemory()
require.NoError(t, err)
defer closeStore(t, store)

require.NoError(t, store.ResetSyncState(ctx, "missing-identity"))
}

func closeStore(t *testing.T, store *Store) {
t.Helper()
require.NoError(t, store.Close())
Expand Down