diff --git a/README.md b/README.md index ad2da68..21e51ef 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bitboxsync/collection_external_test.go b/bitboxsync/collection_external_test.go index 5e2537d..110d7ca 100644 --- a/bitboxsync/collection_external_test.go +++ b/bitboxsync/collection_external_test.go @@ -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() diff --git a/bitboxsync/types.go b/bitboxsync/types.go index 9d047ec..71f9ee4 100644 --- a/bitboxsync/types.go +++ b/bitboxsync/types.go @@ -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) diff --git a/storage/sqlite/store.go b/storage/sqlite/store.go index 277efc0..c138c2f 100644 --- a/storage/sqlite/store.go +++ b/storage/sqlite/store.go @@ -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 diff --git a/storage/sqlite/store_test.go b/storage/sqlite/store_test.go index fb1679b..377fe24 100644 --- a/storage/sqlite/store_test.go +++ b/storage/sqlite/store_test.go @@ -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())